@akiojin/gwt 4.3.1 → 4.4.1
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/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 +7 -4
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +54 -34
- 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/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +208 -0
- package/src/cli/ui/__tests__/hooks/useGitData.nonblocking.test.tsx +206 -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 +72 -37
- package/src/index.ts +14 -1
|
@@ -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>
|
|
@@ -6,9 +6,10 @@ import { Footer } from "../parts/Footer.js";
|
|
|
6
6
|
import { Select } from "../common/Select.js";
|
|
7
7
|
import { Input } from "../common/Input.js";
|
|
8
8
|
import { LoadingIndicator } from "../common/LoadingIndicator.js";
|
|
9
|
+
import { useSpinnerFrame } from "../common/SpinnerIcon.js";
|
|
9
10
|
import { useAppInput } from "../../hooks/useAppInput.js";
|
|
10
11
|
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
11
|
-
import type { BranchItem, Statistics } from "../../types.js";
|
|
12
|
+
import type { BranchItem, Statistics, BranchViewMode } from "../../types.js";
|
|
12
13
|
import stringWidth from "string-width";
|
|
13
14
|
import stripAnsi from "strip-ansi";
|
|
14
15
|
import chalk from "chalk";
|
|
@@ -64,11 +65,13 @@ type IndicatorColor = "cyan" | "green" | "yellow" | "red";
|
|
|
64
65
|
|
|
65
66
|
interface CleanupIndicator {
|
|
66
67
|
icon: string;
|
|
68
|
+
isSpinning?: boolean;
|
|
67
69
|
color?: IndicatorColor;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
interface CleanupFooterMessage {
|
|
71
73
|
text: string;
|
|
74
|
+
isSpinning?: boolean;
|
|
72
75
|
color?: IndicatorColor;
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -102,6 +105,8 @@ export interface BranchListScreenProps {
|
|
|
102
105
|
testOnFilterModeChange?: (mode: boolean) => void;
|
|
103
106
|
testFilterQuery?: string;
|
|
104
107
|
testOnFilterQueryChange?: (query: string) => void;
|
|
108
|
+
testViewMode?: BranchViewMode;
|
|
109
|
+
testOnViewModeChange?: (mode: BranchViewMode) => void;
|
|
105
110
|
selectedBranches?: string[];
|
|
106
111
|
onToggleSelect?: (branchName: string) => void;
|
|
107
112
|
}
|
|
@@ -129,16 +134,31 @@ export function BranchListScreen({
|
|
|
129
134
|
testOnFilterModeChange,
|
|
130
135
|
testFilterQuery,
|
|
131
136
|
testOnFilterQueryChange,
|
|
137
|
+
testViewMode,
|
|
138
|
+
testOnViewModeChange,
|
|
132
139
|
selectedBranches = [],
|
|
133
140
|
onToggleSelect,
|
|
134
141
|
}: BranchListScreenProps) {
|
|
135
142
|
const { rows } = useTerminalSize();
|
|
136
|
-
const headerText = " Legend: [ ]/[ * ] select 🟢/🔴/⚪ worktree 🛡/⚠ safe";
|
|
137
143
|
const selectedSet = useMemo(
|
|
138
144
|
() => new Set(selectedBranches),
|
|
139
145
|
[selectedBranches],
|
|
140
146
|
);
|
|
141
147
|
|
|
148
|
+
// Check if any indicator needs spinner animation
|
|
149
|
+
const hasSpinningIndicator = useMemo(() => {
|
|
150
|
+
if (!cleanupUI?.indicators) return false;
|
|
151
|
+
return Object.values(cleanupUI.indicators).some((ind) => ind.isSpinning);
|
|
152
|
+
}, [cleanupUI?.indicators]);
|
|
153
|
+
|
|
154
|
+
// Also check footer message for spinner
|
|
155
|
+
const hasSpinningFooter = cleanupUI?.footerMessage?.isSpinning ?? false;
|
|
156
|
+
|
|
157
|
+
// Get spinner frame for all spinning elements
|
|
158
|
+
const spinnerFrame = useSpinnerFrame(
|
|
159
|
+
hasSpinningIndicator || hasSpinningFooter,
|
|
160
|
+
);
|
|
161
|
+
|
|
142
162
|
// Filter state - allow test control via props
|
|
143
163
|
const [internalFilterQuery, setInternalFilterQuery] = useState("");
|
|
144
164
|
const filterQuery =
|
|
@@ -164,6 +184,29 @@ export function BranchListScreen({
|
|
|
164
184
|
[testOnFilterModeChange],
|
|
165
185
|
);
|
|
166
186
|
|
|
187
|
+
// View mode state for filtering by local/remote
|
|
188
|
+
const [internalViewMode, setInternalViewMode] =
|
|
189
|
+
useState<BranchViewMode>("all");
|
|
190
|
+
const viewMode = testViewMode !== undefined ? testViewMode : internalViewMode;
|
|
191
|
+
const setViewMode = useCallback(
|
|
192
|
+
(mode: BranchViewMode) => {
|
|
193
|
+
setInternalViewMode(mode);
|
|
194
|
+
testOnViewModeChange?.(mode);
|
|
195
|
+
},
|
|
196
|
+
[testOnViewModeChange],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Cycle view mode: all → local → remote → all
|
|
200
|
+
const cycleViewMode = useCallback(() => {
|
|
201
|
+
const modes: BranchViewMode[] = ["all", "local", "remote"];
|
|
202
|
+
const currentIndex = modes.indexOf(viewMode);
|
|
203
|
+
const nextIndex = (currentIndex + 1) % modes.length;
|
|
204
|
+
const nextMode = modes[nextIndex];
|
|
205
|
+
if (nextMode !== undefined) {
|
|
206
|
+
setViewMode(nextMode);
|
|
207
|
+
}
|
|
208
|
+
}, [viewMode, setViewMode]);
|
|
209
|
+
|
|
167
210
|
// Cursor position for Select (controlled to enable space toggle)
|
|
168
211
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
169
212
|
|
|
@@ -204,6 +247,13 @@ export function BranchListScreen({
|
|
|
204
247
|
return;
|
|
205
248
|
}
|
|
206
249
|
|
|
250
|
+
// Tab key to cycle view mode (only in branch selection mode)
|
|
251
|
+
if (key.tab && !filterMode) {
|
|
252
|
+
cycleViewMode();
|
|
253
|
+
setSelectedIndex(0); // Reset cursor position on mode change
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
207
257
|
// Disable global shortcuts while in filter mode
|
|
208
258
|
if (filterMode) {
|
|
209
259
|
return;
|
|
@@ -219,27 +269,35 @@ export function BranchListScreen({
|
|
|
219
269
|
}
|
|
220
270
|
});
|
|
221
271
|
|
|
222
|
-
// Filter branches based on query
|
|
272
|
+
// Filter branches based on view mode and query
|
|
223
273
|
const filteredBranches = useMemo(() => {
|
|
224
|
-
|
|
225
|
-
|
|
274
|
+
let result = branches;
|
|
275
|
+
|
|
276
|
+
// Apply view mode filter
|
|
277
|
+
if (viewMode !== "all") {
|
|
278
|
+
result = result.filter((branch) => branch.type === viewMode);
|
|
226
279
|
}
|
|
227
280
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
281
|
+
// Apply search filter
|
|
282
|
+
if (filterQuery.trim()) {
|
|
283
|
+
const query = filterQuery.toLowerCase();
|
|
284
|
+
result = result.filter((branch) => {
|
|
285
|
+
// Search in branch name
|
|
286
|
+
if (branch.name.toLowerCase().includes(query)) {
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
234
289
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
290
|
+
// Search in PR title if available (only openPR has title)
|
|
291
|
+
if (branch.openPR?.title?.toLowerCase().includes(query)) {
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
239
294
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
295
|
+
return false;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return result;
|
|
300
|
+
}, [branches, viewMode, filterQuery]);
|
|
243
301
|
|
|
244
302
|
useEffect(() => {
|
|
245
303
|
setSelectedIndex((prev) => {
|
|
@@ -271,6 +329,7 @@ export function BranchListScreen({
|
|
|
271
329
|
const footerActions = [
|
|
272
330
|
{ key: "enter", description: "Select" },
|
|
273
331
|
{ key: "f", description: "Filter" },
|
|
332
|
+
{ key: "tab", description: "Mode" },
|
|
274
333
|
{ key: "r", description: "Refresh" },
|
|
275
334
|
{ key: "c", description: "Cleanup" },
|
|
276
335
|
{ key: "p", description: "Profiles" },
|
|
@@ -348,7 +407,6 @@ export function BranchListScreen({
|
|
|
348
407
|
const columns = Math.max(20, context.columns - 1);
|
|
349
408
|
const visibleWidth = (value: string) =>
|
|
350
409
|
measureDisplayWidth(stripAnsi(value));
|
|
351
|
-
const arrow = isSelected ? ">" : " ";
|
|
352
410
|
let commitText = "---";
|
|
353
411
|
if (item.latestCommitTimestamp) {
|
|
354
412
|
commitText = formatLatestCommit(item.latestCommitTimestamp);
|
|
@@ -384,27 +442,38 @@ export function BranchListScreen({
|
|
|
384
442
|
)} | ${paddedDate}`;
|
|
385
443
|
const timestampWidth = measureDisplayWidth(timestampText);
|
|
386
444
|
|
|
445
|
+
// Determine the leading indicator (cursor or cleanup status)
|
|
387
446
|
const indicatorInfo = cleanupUI?.indicators?.[item.name];
|
|
388
|
-
let
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
447
|
+
let leadingIndicator: string;
|
|
448
|
+
if (indicatorInfo) {
|
|
449
|
+
// Use spinner frame if isSpinning, otherwise use static icon
|
|
450
|
+
let indicatorIcon =
|
|
451
|
+
indicatorInfo.isSpinning && spinnerFrame
|
|
452
|
+
? spinnerFrame
|
|
453
|
+
: indicatorInfo.icon;
|
|
454
|
+
if (indicatorIcon && indicatorInfo.color && !isSelected) {
|
|
455
|
+
switch (indicatorInfo.color) {
|
|
456
|
+
case "cyan":
|
|
457
|
+
indicatorIcon = chalk.cyan(indicatorIcon);
|
|
458
|
+
break;
|
|
459
|
+
case "green":
|
|
460
|
+
indicatorIcon = chalk.green(indicatorIcon);
|
|
461
|
+
break;
|
|
462
|
+
case "yellow":
|
|
463
|
+
indicatorIcon = chalk.yellow(indicatorIcon);
|
|
464
|
+
break;
|
|
465
|
+
case "red":
|
|
466
|
+
indicatorIcon = chalk.red(indicatorIcon);
|
|
467
|
+
break;
|
|
468
|
+
default:
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
405
471
|
}
|
|
472
|
+
leadingIndicator = indicatorIcon;
|
|
473
|
+
} else {
|
|
474
|
+
// Normal cursor
|
|
475
|
+
leadingIndicator = isSelected ? ">" : " ";
|
|
406
476
|
}
|
|
407
|
-
const indicatorPrefix = indicatorIcon ? `${indicatorIcon} ` : "";
|
|
408
477
|
|
|
409
478
|
const isChecked = selectedSet.has(item.name);
|
|
410
479
|
const isWarning = Boolean(item.hasUnpushedCommits) || !item.mergedPR;
|
|
@@ -423,7 +492,7 @@ export function BranchListScreen({
|
|
|
423
492
|
item.safeToCleanup === true ? chalk.green("🛡") : chalk.yellow("⚠");
|
|
424
493
|
const stateCluster = `${selectionIcon} ${worktreeIcon} ${safeIcon}`;
|
|
425
494
|
|
|
426
|
-
const staticPrefix = `${
|
|
495
|
+
const staticPrefix = `${leadingIndicator} ${stateCluster} `;
|
|
427
496
|
const staticPrefixWidth = visibleWidth(staticPrefix);
|
|
428
497
|
const maxLeftDisplayWidth = Math.max(0, columns - timestampWidth - 1);
|
|
429
498
|
const maxLabelWidth = Math.max(
|
|
@@ -519,6 +588,7 @@ export function BranchListScreen({
|
|
|
519
588
|
truncateToWidth,
|
|
520
589
|
selectedSet,
|
|
521
590
|
colorToolLabel,
|
|
591
|
+
spinnerFrame,
|
|
522
592
|
],
|
|
523
593
|
);
|
|
524
594
|
|
|
@@ -557,7 +627,7 @@ export function BranchListScreen({
|
|
|
557
627
|
|
|
558
628
|
{/* Stats */}
|
|
559
629
|
<Box>
|
|
560
|
-
<Stats stats={stats} lastUpdated={lastUpdated} />
|
|
630
|
+
<Stats stats={stats} lastUpdated={lastUpdated} viewMode={viewMode} />
|
|
561
631
|
</Box>
|
|
562
632
|
|
|
563
633
|
{/* Content */}
|
|
@@ -602,10 +672,6 @@ export function BranchListScreen({
|
|
|
602
672
|
branches.length > 0 &&
|
|
603
673
|
filteredBranches.length > 0 && (
|
|
604
674
|
<>
|
|
605
|
-
{/* Column labels */}
|
|
606
|
-
<Box>
|
|
607
|
-
<Text dimColor>{headerText}</Text>
|
|
608
|
-
</Box>
|
|
609
675
|
<Select
|
|
610
676
|
items={filteredBranches}
|
|
611
677
|
onSelect={onSelect}
|
|
@@ -624,10 +690,16 @@ export function BranchListScreen({
|
|
|
624
690
|
<Box marginBottom={1}>
|
|
625
691
|
{cleanupUI.footerMessage.color ? (
|
|
626
692
|
<Text color={cleanupUI.footerMessage.color}>
|
|
627
|
-
{cleanupUI.footerMessage.
|
|
693
|
+
{cleanupUI.footerMessage.isSpinning && spinnerFrame
|
|
694
|
+
? `${spinnerFrame} ${cleanupUI.footerMessage.text}`
|
|
695
|
+
: cleanupUI.footerMessage.text}
|
|
628
696
|
</Text>
|
|
629
697
|
) : (
|
|
630
|
-
<Text>
|
|
698
|
+
<Text>
|
|
699
|
+
{cleanupUI.footerMessage.isSpinning && spinnerFrame
|
|
700
|
+
? `${spinnerFrame} ${cleanupUI.footerMessage.text}`
|
|
701
|
+
: cleanupUI.footerMessage.text}
|
|
702
|
+
</Text>
|
|
631
703
|
)}
|
|
632
704
|
</Box>
|
|
633
705
|
)}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { useInput, type Key } from "ink";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// WSL/Windows can emit split escape sequences with higher latency.
|
|
5
|
+
export const ESCAPE_SEQUENCE_TIMEOUT_MS = 80;
|
|
5
6
|
|
|
6
7
|
type InputHandler = (input: string, key: Key) => void;
|
|
7
8
|
type Options = { isActive?: boolean };
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
collectUpstreamMap,
|
|
8
8
|
getBranchDivergenceStatuses,
|
|
9
9
|
} from "../../../git.js";
|
|
10
|
+
import { GIT_CONFIG } from "../../../config/constants.js";
|
|
10
11
|
import { listAdditionalWorktrees } from "../../../worktree.js";
|
|
11
12
|
import { getPullRequestByBranch } from "../../../github.js";
|
|
12
13
|
import type { BranchInfo, WorktreeInfo } from "../types.js";
|
|
@@ -28,6 +29,45 @@ export interface UseGitDataResult {
|
|
|
28
29
|
lastUpdated: Date | null;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
export const GIT_DATA_TIMEOUT_MS = 3000;
|
|
33
|
+
const PER_BRANCH_TIMEOUT_MS = 1000;
|
|
34
|
+
|
|
35
|
+
async function withTimeout<T>(
|
|
36
|
+
label: string,
|
|
37
|
+
promise: Promise<T>,
|
|
38
|
+
timeoutMs: number,
|
|
39
|
+
fallback: T,
|
|
40
|
+
): Promise<T> {
|
|
41
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
42
|
+
let timedOut = false;
|
|
43
|
+
|
|
44
|
+
const timeoutPromise = new Promise<T>((resolve) => {
|
|
45
|
+
timeoutId = setTimeout(() => {
|
|
46
|
+
timedOut = true;
|
|
47
|
+
resolve(fallback);
|
|
48
|
+
}, timeoutMs);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const guarded = promise.catch((error) => {
|
|
52
|
+
if (process.env.DEBUG) {
|
|
53
|
+
console.warn(`Failed to resolve ${label}`, error);
|
|
54
|
+
}
|
|
55
|
+
return fallback;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = await Promise.race([guarded, timeoutPromise]);
|
|
59
|
+
|
|
60
|
+
if (timedOut && process.env.DEBUG) {
|
|
61
|
+
console.warn(`Timed out waiting for ${label}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (timeoutId) {
|
|
65
|
+
clearTimeout(timeoutId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
31
71
|
/**
|
|
32
72
|
* Hook to fetch and manage Git data (branches and worktrees)
|
|
33
73
|
* @param options - Configuration options for auto-refresh and polling interval
|
|
@@ -45,21 +85,37 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
45
85
|
setError(null);
|
|
46
86
|
|
|
47
87
|
try {
|
|
48
|
-
const repoRoot = await
|
|
88
|
+
const repoRoot = await withTimeout(
|
|
89
|
+
"repository root",
|
|
90
|
+
getRepositoryRoot(),
|
|
91
|
+
GIT_DATA_TIMEOUT_MS,
|
|
92
|
+
process.cwd(),
|
|
93
|
+
);
|
|
49
94
|
|
|
50
95
|
// リモートブランチの最新情報を取得(失敗してもローカル表示は継続)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
96
|
+
void fetchAllRemotes({
|
|
97
|
+
cwd: repoRoot,
|
|
98
|
+
timeoutMs: GIT_CONFIG.FETCH_TIMEOUT,
|
|
99
|
+
}).catch((fetchError) => {
|
|
54
100
|
if (process.env.DEBUG) {
|
|
55
101
|
console.warn("Failed to fetch remote branches", fetchError);
|
|
56
102
|
}
|
|
57
|
-
}
|
|
103
|
+
});
|
|
58
104
|
|
|
59
|
-
const branchesData = await
|
|
105
|
+
const branchesData = await withTimeout(
|
|
106
|
+
"branches",
|
|
107
|
+
getAllBranches(repoRoot),
|
|
108
|
+
GIT_DATA_TIMEOUT_MS,
|
|
109
|
+
[],
|
|
110
|
+
);
|
|
60
111
|
let worktreesData: GitWorktreeInfo[] = [];
|
|
61
112
|
try {
|
|
62
|
-
worktreesData = await
|
|
113
|
+
worktreesData = await withTimeout(
|
|
114
|
+
"worktrees",
|
|
115
|
+
listAdditionalWorktrees(),
|
|
116
|
+
GIT_DATA_TIMEOUT_MS,
|
|
117
|
+
[],
|
|
118
|
+
);
|
|
63
119
|
} catch (err) {
|
|
64
120
|
if (process.env.DEBUG) {
|
|
65
121
|
console.error("Failed to list additional worktrees:", err);
|
|
@@ -74,7 +130,12 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
74
130
|
return wt;
|
|
75
131
|
}
|
|
76
132
|
try {
|
|
77
|
-
const hasUncommitted = await
|
|
133
|
+
const hasUncommitted = await withTimeout(
|
|
134
|
+
"worktree status",
|
|
135
|
+
hasUncommittedChanges(wt.path),
|
|
136
|
+
PER_BRANCH_TIMEOUT_MS,
|
|
137
|
+
false,
|
|
138
|
+
);
|
|
78
139
|
return { ...wt, hasUncommittedChanges: hasUncommitted };
|
|
79
140
|
} catch {
|
|
80
141
|
return wt;
|
|
@@ -82,12 +143,27 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
82
143
|
}),
|
|
83
144
|
);
|
|
84
145
|
|
|
85
|
-
const lastToolUsageMap = await
|
|
146
|
+
const lastToolUsageMap = await withTimeout(
|
|
147
|
+
"last tool usage",
|
|
148
|
+
getLastToolUsageMap(repoRoot),
|
|
149
|
+
GIT_DATA_TIMEOUT_MS,
|
|
150
|
+
new Map(),
|
|
151
|
+
);
|
|
86
152
|
|
|
87
153
|
// upstream情報とdivergence情報を取得
|
|
88
154
|
const [upstreamMap, divergenceStatuses] = await Promise.all([
|
|
89
|
-
|
|
90
|
-
|
|
155
|
+
withTimeout(
|
|
156
|
+
"upstream map",
|
|
157
|
+
collectUpstreamMap(repoRoot),
|
|
158
|
+
GIT_DATA_TIMEOUT_MS,
|
|
159
|
+
new Map<string, string>(),
|
|
160
|
+
),
|
|
161
|
+
withTimeout(
|
|
162
|
+
"divergence",
|
|
163
|
+
getBranchDivergenceStatuses({ cwd: repoRoot }).catch(() => []),
|
|
164
|
+
GIT_DATA_TIMEOUT_MS,
|
|
165
|
+
[],
|
|
166
|
+
),
|
|
91
167
|
]);
|
|
92
168
|
|
|
93
169
|
// divergenceをMapに変換
|
|
@@ -153,13 +229,20 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
153
229
|
if (branch.type === "local") {
|
|
154
230
|
try {
|
|
155
231
|
// Check for unpushed commits
|
|
156
|
-
hasUnpushed = await
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
232
|
+
[hasUnpushed, prInfo] = await Promise.all([
|
|
233
|
+
withTimeout(
|
|
234
|
+
"unpushed commits",
|
|
235
|
+
hasUnpushedCommitsInRepo(branch.name, repoRoot),
|
|
236
|
+
PER_BRANCH_TIMEOUT_MS,
|
|
237
|
+
false,
|
|
238
|
+
),
|
|
239
|
+
withTimeout(
|
|
240
|
+
"pull request",
|
|
241
|
+
getPullRequestByBranch(branch.name),
|
|
242
|
+
PER_BRANCH_TIMEOUT_MS,
|
|
243
|
+
null,
|
|
244
|
+
),
|
|
245
|
+
]);
|
|
163
246
|
} catch (error) {
|
|
164
247
|
// Silently ignore errors to avoid breaking the UI
|
|
165
248
|
if (process.env.DEBUG) {
|
|
@@ -6,6 +6,7 @@ import { act, render } from "@testing-library/react";
|
|
|
6
6
|
import { render as inkRender } from "ink-testing-library";
|
|
7
7
|
import React from "react";
|
|
8
8
|
import { BranchActionSelectorScreen } from "../BranchActionSelectorScreen.js";
|
|
9
|
+
import { ESCAPE_SEQUENCE_TIMEOUT_MS } from "../../hooks/useAppInput.js";
|
|
9
10
|
import { Window } from "happy-dom";
|
|
10
11
|
|
|
11
12
|
describe("BranchActionSelectorScreen", () => {
|
|
@@ -182,6 +183,50 @@ describe("BranchActionSelectorScreen", () => {
|
|
|
182
183
|
inkApp.unmount();
|
|
183
184
|
});
|
|
184
185
|
|
|
186
|
+
it("should treat delayed split down-arrow sequence as navigation (WSL2) and not as Escape", () => {
|
|
187
|
+
vi.useFakeTimers();
|
|
188
|
+
let inkApp: ReturnType<typeof inkRender> | undefined;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const onUseExisting = vi.fn();
|
|
192
|
+
const onCreateNew = vi.fn();
|
|
193
|
+
const onBack = vi.fn();
|
|
194
|
+
|
|
195
|
+
inkApp = inkRender(
|
|
196
|
+
<BranchActionSelectorScreen
|
|
197
|
+
selectedBranch="feature-test"
|
|
198
|
+
onUseExisting={onUseExisting}
|
|
199
|
+
onCreateNew={onCreateNew}
|
|
200
|
+
onBack={onBack}
|
|
201
|
+
/>,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
act(() => {
|
|
205
|
+
inkApp.stdin.write("\u001b");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
act(() => {
|
|
209
|
+
vi.advanceTimersByTime(ESCAPE_SEQUENCE_TIMEOUT_MS - 10);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
act(() => {
|
|
213
|
+
inkApp.stdin.write("[");
|
|
214
|
+
inkApp.stdin.write("B");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
act(() => {
|
|
218
|
+
inkApp.stdin.write("\r");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(onBack).not.toHaveBeenCalled();
|
|
222
|
+
expect(onCreateNew).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(onUseExisting).not.toHaveBeenCalled();
|
|
224
|
+
} finally {
|
|
225
|
+
inkApp?.unmount();
|
|
226
|
+
vi.useRealTimers();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
185
230
|
it("should still handle Escape key as back navigation", () => {
|
|
186
231
|
vi.useFakeTimers();
|
|
187
232
|
let inkApp: ReturnType<typeof inkRender> | undefined;
|
|
@@ -205,7 +250,7 @@ describe("BranchActionSelectorScreen", () => {
|
|
|
205
250
|
});
|
|
206
251
|
|
|
207
252
|
act(() => {
|
|
208
|
-
vi.advanceTimersByTime(
|
|
253
|
+
vi.advanceTimersByTime(ESCAPE_SEQUENCE_TIMEOUT_MS);
|
|
209
254
|
});
|
|
210
255
|
|
|
211
256
|
expect(onBack).toHaveBeenCalledTimes(1);
|
package/src/cli/ui/types.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface WorktreeInfo {
|
|
|
12
12
|
export type AITool = string;
|
|
13
13
|
export type InferenceLevel = "low" | "medium" | "high" | "xhigh";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Branch view mode for filtering branch list by type
|
|
17
|
+
*/
|
|
18
|
+
export type BranchViewMode = "all" | "local" | "remote";
|
|
19
|
+
|
|
15
20
|
export interface ModelOption {
|
|
16
21
|
id: string;
|
|
17
22
|
label: string;
|