@akiojin/gwt 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.ja.md +323 -0
  2. package/README.md +347 -0
  3. package/bin/gwt.js +5 -0
  4. package/package.json +125 -0
  5. package/src/claude-history.ts +717 -0
  6. package/src/claude.ts +292 -0
  7. package/src/cli/ui/__tests__/SKIPPED_TESTS.md +119 -0
  8. package/src/cli/ui/__tests__/acceptance/branchList.acceptance.test.tsx.skip +239 -0
  9. package/src/cli/ui/__tests__/acceptance/navigation.acceptance.test.tsx +214 -0
  10. package/src/cli/ui/__tests__/acceptance/realtimeUpdate.acceptance.test.tsx.skip +219 -0
  11. package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +183 -0
  12. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +313 -0
  13. package/src/cli/ui/__tests__/components/App.test.tsx +270 -0
  14. package/src/cli/ui/__tests__/components/common/Confirm.test.tsx +66 -0
  15. package/src/cli/ui/__tests__/components/common/ErrorBoundary.test.tsx +103 -0
  16. package/src/cli/ui/__tests__/components/common/Input.test.tsx +92 -0
  17. package/src/cli/ui/__tests__/components/common/LoadingIndicator.test.tsx +127 -0
  18. package/src/cli/ui/__tests__/components/common/Select.memo.test.tsx +264 -0
  19. package/src/cli/ui/__tests__/components/common/Select.test.tsx +246 -0
  20. package/src/cli/ui/__tests__/components/parts/Footer.test.tsx +62 -0
  21. package/src/cli/ui/__tests__/components/parts/Header.test.tsx +54 -0
  22. package/src/cli/ui/__tests__/components/parts/ScrollableList.test.tsx +68 -0
  23. package/src/cli/ui/__tests__/components/parts/Stats.test.tsx +135 -0
  24. package/src/cli/ui/__tests__/components/screens/AIToolSelectorScreen.test.tsx +153 -0
  25. package/src/cli/ui/__tests__/components/screens/BranchCreatorScreen.test.tsx +215 -0
  26. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +293 -0
  27. package/src/cli/ui/__tests__/components/screens/ExecutionModeSelectorScreen.test.tsx +161 -0
  28. package/src/cli/ui/__tests__/components/screens/PRCleanupScreen.test.tsx +215 -0
  29. package/src/cli/ui/__tests__/components/screens/SessionSelectorScreen.test.tsx +99 -0
  30. package/src/cli/ui/__tests__/components/screens/WorktreeManagerScreen.test.tsx +127 -0
  31. package/src/cli/ui/__tests__/hooks/useGitData.test.ts.skip +228 -0
  32. package/src/cli/ui/__tests__/hooks/useScreenState.test.ts +146 -0
  33. package/src/cli/ui/__tests__/hooks/useTerminalSize.test.ts +98 -0
  34. package/src/cli/ui/__tests__/integration/branchList.test.tsx.skip +253 -0
  35. package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +306 -0
  36. package/src/cli/ui/__tests__/integration/navigation.test.tsx +405 -0
  37. package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx +505 -0
  38. package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx.skip +216 -0
  39. package/src/cli/ui/__tests__/performance/branchList.performance.test.tsx +180 -0
  40. package/src/cli/ui/__tests__/performance/useMemoOptimization.test.tsx +237 -0
  41. package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +775 -0
  42. package/src/cli/ui/__tests__/utils/statisticsCalculator.test.ts +243 -0
  43. package/src/cli/ui/components/App.tsx +793 -0
  44. package/src/cli/ui/components/common/Confirm.tsx +40 -0
  45. package/src/cli/ui/components/common/ErrorBoundary.tsx +57 -0
  46. package/src/cli/ui/components/common/Input.tsx +36 -0
  47. package/src/cli/ui/components/common/LoadingIndicator.tsx +95 -0
  48. package/src/cli/ui/components/common/Select.tsx +216 -0
  49. package/src/cli/ui/components/parts/Footer.tsx +41 -0
  50. package/src/cli/ui/components/parts/Header.test.tsx +85 -0
  51. package/src/cli/ui/components/parts/Header.tsx +63 -0
  52. package/src/cli/ui/components/parts/MergeStatusList.tsx +75 -0
  53. package/src/cli/ui/components/parts/ProgressBar.tsx +73 -0
  54. package/src/cli/ui/components/parts/ScrollableList.tsx +24 -0
  55. package/src/cli/ui/components/parts/Stats.tsx +67 -0
  56. package/src/cli/ui/components/screens/AIToolSelectorScreen.tsx +116 -0
  57. package/src/cli/ui/components/screens/BatchMergeProgressScreen.tsx +70 -0
  58. package/src/cli/ui/components/screens/BatchMergeResultScreen.tsx +104 -0
  59. package/src/cli/ui/components/screens/BranchCreatorScreen.tsx +213 -0
  60. package/src/cli/ui/components/screens/BranchListScreen.tsx +299 -0
  61. package/src/cli/ui/components/screens/ExecutionModeSelectorScreen.tsx +149 -0
  62. package/src/cli/ui/components/screens/PRCleanupScreen.tsx +167 -0
  63. package/src/cli/ui/components/screens/SessionSelectorScreen.tsx +100 -0
  64. package/src/cli/ui/components/screens/WorktreeManagerScreen.tsx +117 -0
  65. package/src/cli/ui/hooks/useBatchMerge.ts +96 -0
  66. package/src/cli/ui/hooks/useGitData.ts +157 -0
  67. package/src/cli/ui/hooks/useScreenState.ts +44 -0
  68. package/src/cli/ui/hooks/useTerminalSize.ts +33 -0
  69. package/src/cli/ui/screens/BranchActionSelectorScreen.tsx +102 -0
  70. package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +151 -0
  71. package/src/cli/ui/types.ts +295 -0
  72. package/src/cli/ui/utils/baseBranch.ts +34 -0
  73. package/src/cli/ui/utils/branchFormatter.ts +222 -0
  74. package/src/cli/ui/utils/statisticsCalculator.ts +44 -0
  75. package/src/codex.ts +139 -0
  76. package/src/config/builtin-tools.ts +44 -0
  77. package/src/config/constants.ts +100 -0
  78. package/src/config/env-history.ts +45 -0
  79. package/src/config/index.ts +204 -0
  80. package/src/config/tools.ts +293 -0
  81. package/src/git.ts +1102 -0
  82. package/src/github.ts +158 -0
  83. package/src/index.test.ts +87 -0
  84. package/src/index.ts +684 -0
  85. package/src/index.ts.backup +1543 -0
  86. package/src/launcher.ts +142 -0
  87. package/src/repositories/git.repository.ts +129 -0
  88. package/src/repositories/github.repository.ts +83 -0
  89. package/src/repositories/worktree.repository.ts +69 -0
  90. package/src/services/BatchMergeService.ts +251 -0
  91. package/src/services/WorktreeOrchestrator.ts +115 -0
  92. package/src/services/__tests__/BatchMergeService.test.ts +518 -0
  93. package/src/services/__tests__/WorktreeOrchestrator.test.ts +258 -0
  94. package/src/services/dependency-installer.ts +199 -0
  95. package/src/services/git.service.ts +113 -0
  96. package/src/services/github.service.ts +61 -0
  97. package/src/services/worktree.service.ts +66 -0
  98. package/src/types/api.ts +241 -0
  99. package/src/types/tools.ts +235 -0
  100. package/src/utils/spinner.ts +54 -0
  101. package/src/utils/terminal.ts +272 -0
  102. package/src/utils.test.ts +43 -0
  103. package/src/utils.ts +60 -0
  104. package/src/web/client/index.html +12 -0
  105. package/src/web/client/src/components/BranchGraph.tsx +231 -0
  106. package/src/web/client/src/components/EnvEditor.tsx +145 -0
  107. package/src/web/client/src/components/Terminal.tsx +137 -0
  108. package/src/web/client/src/hooks/useBranches.ts +41 -0
  109. package/src/web/client/src/hooks/useConfig.ts +31 -0
  110. package/src/web/client/src/hooks/useSessions.ts +59 -0
  111. package/src/web/client/src/hooks/useWorktrees.ts +47 -0
  112. package/src/web/client/src/index.css +834 -0
  113. package/src/web/client/src/lib/api.ts +184 -0
  114. package/src/web/client/src/lib/websocket.ts +174 -0
  115. package/src/web/client/src/main.tsx +29 -0
  116. package/src/web/client/src/pages/BranchDetailPage.tsx +847 -0
  117. package/src/web/client/src/pages/BranchListPage.tsx +264 -0
  118. package/src/web/client/src/pages/ConfigManagementPage.tsx +203 -0
  119. package/src/web/client/src/router.tsx +27 -0
  120. package/src/web/client/vite.config.ts +21 -0
  121. package/src/web/server/env/importer.ts +54 -0
  122. package/src/web/server/index.ts +74 -0
  123. package/src/web/server/pty/manager.ts +189 -0
  124. package/src/web/server/routes/branches.ts +126 -0
  125. package/src/web/server/routes/config.ts +220 -0
  126. package/src/web/server/routes/index.ts +37 -0
  127. package/src/web/server/routes/sessions.ts +130 -0
  128. package/src/web/server/routes/worktrees.ts +108 -0
  129. package/src/web/server/services/branches.ts +368 -0
  130. package/src/web/server/services/worktrees.ts +85 -0
  131. package/src/web/server/websocket/handler.ts +180 -0
  132. package/src/worktree.ts +703 -0
@@ -0,0 +1,272 @@
1
+ import fs from "node:fs";
2
+ import { platform } from "node:os";
3
+ import { ReadStream, WriteStream } from "node:tty";
4
+
5
+ export interface TerminalStreams {
6
+ stdin: NodeJS.ReadStream;
7
+ stdout: NodeJS.WriteStream;
8
+ stderr: NodeJS.WriteStream;
9
+ stdinFd?: number;
10
+ stdoutFd?: number;
11
+ stderrFd?: number;
12
+ usingFallback: boolean;
13
+ exitRawMode: () => void;
14
+ }
15
+
16
+ const DEV_TTY_PATH = "/dev/tty";
17
+
18
+ let cachedStreams: TerminalStreams | null = null;
19
+
20
+ export interface ChildStdio {
21
+ stdin: "inherit" | number;
22
+ stdout: "inherit" | number;
23
+ stderr: "inherit" | number;
24
+ cleanup: () => void;
25
+ }
26
+
27
+ const DEFAULT_ACK_MESSAGE =
28
+ "Review the error details, then press Enter to continue...";
29
+
30
+ function isProcessTTY(): boolean {
31
+ return Boolean(
32
+ process.stdin.isTTY &&
33
+ process.stdout.isTTY &&
34
+ process.stderr.isTTY &&
35
+ typeof (process.stdin as NodeJS.ReadStream).setRawMode === "function",
36
+ );
37
+ }
38
+
39
+ function createTerminalStreams(): TerminalStreams {
40
+ if (isProcessTTY()) {
41
+ const exitRawMode = () => {
42
+ const stream = process.stdin as NodeJS.ReadStream;
43
+ if (typeof stream.setRawMode === "function") {
44
+ try {
45
+ stream.setRawMode(false);
46
+ } catch {
47
+ // Ignore errors when resetting raw mode.
48
+ }
49
+ }
50
+ };
51
+
52
+ return {
53
+ stdin: process.stdin,
54
+ stdout: process.stdout,
55
+ stderr: process.stderr,
56
+ usingFallback: false,
57
+ exitRawMode,
58
+ };
59
+ }
60
+
61
+ // Windows では /dev/tty が利用できないため、そのまま返す。
62
+ if (platform() === "win32") {
63
+ return {
64
+ stdin: process.stdin,
65
+ stdout: process.stdout,
66
+ stderr: process.stderr,
67
+ usingFallback: false,
68
+ exitRawMode: () => {
69
+ const stream = process.stdin as NodeJS.ReadStream;
70
+ if (typeof stream.setRawMode === "function") {
71
+ try {
72
+ stream.setRawMode(false);
73
+ } catch {
74
+ // Ignore errors when resetting raw mode.
75
+ }
76
+ }
77
+ },
78
+ };
79
+ }
80
+
81
+ try {
82
+ const fdIn = fs.openSync(DEV_TTY_PATH, "r");
83
+ const fdOut = fs.openSync(DEV_TTY_PATH, "w");
84
+ const fdErr = fs.openSync(DEV_TTY_PATH, "w");
85
+
86
+ const stdin = new ReadStream(fdIn);
87
+ const stdout = new WriteStream(fdOut);
88
+ const stderr = new WriteStream(fdErr);
89
+
90
+ const exitRawMode = () => {
91
+ if (typeof stdin.setRawMode === "function") {
92
+ try {
93
+ stdin.setRawMode(false);
94
+ } catch {
95
+ // Ignore errors when resetting raw mode.
96
+ }
97
+ }
98
+ };
99
+
100
+ const cleanup = () => {
101
+ exitRawMode();
102
+ try {
103
+ stdin.destroy();
104
+ } catch {
105
+ // Ignore stdin destroy errors.
106
+ }
107
+ try {
108
+ stdout.destroy();
109
+ } catch {
110
+ // Ignore stdout destroy errors.
111
+ }
112
+ try {
113
+ stderr.destroy();
114
+ } catch {
115
+ // Ignore stderr destroy errors.
116
+ }
117
+ try {
118
+ fs.closeSync(fdIn);
119
+ } catch {
120
+ // Ignore close errors.
121
+ }
122
+ try {
123
+ fs.closeSync(fdOut);
124
+ } catch {
125
+ // Ignore close errors.
126
+ }
127
+ try {
128
+ fs.closeSync(fdErr);
129
+ } catch {
130
+ // Ignore close errors.
131
+ }
132
+ };
133
+
134
+ process.once("exit", cleanup);
135
+
136
+ return {
137
+ stdin,
138
+ stdout,
139
+ stderr,
140
+ stdinFd: fdIn,
141
+ stdoutFd: fdOut,
142
+ stderrFd: fdErr,
143
+ usingFallback: true,
144
+ exitRawMode,
145
+ };
146
+ } catch {
147
+ const exitRawMode = () => {
148
+ const stream = process.stdin as NodeJS.ReadStream;
149
+ if (typeof stream.setRawMode === "function") {
150
+ try {
151
+ stream.setRawMode(false);
152
+ } catch {
153
+ // Ignore errors when resetting raw mode.
154
+ }
155
+ }
156
+ };
157
+
158
+ return {
159
+ stdin: process.stdin,
160
+ stdout: process.stdout,
161
+ stderr: process.stderr,
162
+ usingFallback: false,
163
+ exitRawMode,
164
+ };
165
+ }
166
+ }
167
+
168
+ export function getTerminalStreams(): TerminalStreams {
169
+ if (!cachedStreams) {
170
+ cachedStreams = createTerminalStreams();
171
+ }
172
+ return cachedStreams;
173
+ }
174
+
175
+ export function createChildStdio(): ChildStdio {
176
+ const terminal = getTerminalStreams();
177
+
178
+ if (!terminal.usingFallback) {
179
+ return {
180
+ stdin: "inherit",
181
+ stdout: "inherit",
182
+ stderr: "inherit",
183
+ cleanup: () => {},
184
+ };
185
+ }
186
+
187
+ let fdIn: number | null = null;
188
+ let fdOut: number | null = null;
189
+ let fdErr: number | null = null;
190
+
191
+ const cleanup = () => {
192
+ for (const fd of [fdIn, fdOut, fdErr]) {
193
+ if (fd !== null) {
194
+ try {
195
+ fs.closeSync(fd);
196
+ } catch {
197
+ // Ignore close errors.
198
+ }
199
+ }
200
+ }
201
+ };
202
+
203
+ try {
204
+ fdIn = fs.openSync(DEV_TTY_PATH, "r");
205
+ fdOut = fs.openSync(DEV_TTY_PATH, "w");
206
+ fdErr = fs.openSync(DEV_TTY_PATH, "w");
207
+
208
+ return {
209
+ stdin: fdIn,
210
+ stdout: fdOut,
211
+ stderr: fdErr,
212
+ cleanup,
213
+ };
214
+ } catch {
215
+ cleanup();
216
+ return {
217
+ stdin: "inherit",
218
+ stdout: "inherit",
219
+ stderr: "inherit",
220
+ cleanup: () => {},
221
+ };
222
+ }
223
+ }
224
+
225
+ function isInteractive(stream: NodeJS.ReadStream): boolean {
226
+ return Boolean(stream.isTTY);
227
+ }
228
+
229
+ export async function waitForUserAcknowledgement(
230
+ message: string = DEFAULT_ACK_MESSAGE,
231
+ ): Promise<void> {
232
+ const terminal = getTerminalStreams();
233
+ const stdin = terminal.stdin as NodeJS.ReadStream;
234
+ const stdout = terminal.stdout as NodeJS.WriteStream;
235
+
236
+ if (!stdin || typeof stdin.on !== "function") {
237
+ return;
238
+ }
239
+
240
+ if (!isInteractive(stdin)) {
241
+ return;
242
+ }
243
+
244
+ terminal.exitRawMode();
245
+
246
+ await new Promise<void>((resolve) => {
247
+ const cleanup = () => {
248
+ stdin.removeListener("data", onData);
249
+ if (typeof stdin.pause === "function") {
250
+ stdin.pause();
251
+ }
252
+ };
253
+
254
+ const onData = (chunk: Buffer | string) => {
255
+ const data = typeof chunk === "string" ? chunk : chunk.toString("utf8");
256
+ if (data.includes("\n") || data.includes("\r")) {
257
+ cleanup();
258
+ resolve();
259
+ }
260
+ };
261
+
262
+ if (typeof stdout?.write === "function") {
263
+ stdout.write(`\n${message}\n`);
264
+ }
265
+
266
+ if (typeof stdin.resume === "function") {
267
+ stdin.resume();
268
+ }
269
+
270
+ stdin.on("data", onData);
271
+ });
272
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { getPackageVersion } from "./utils";
3
+ import { readFile } from "fs/promises";
4
+ import path from "node:path";
5
+
6
+ describe("getPackageVersion", () => {
7
+ it("正常系: package.jsonが存在し、versionフィールドがある場合、バージョンを返す", async () => {
8
+ const version = await getPackageVersion();
9
+
10
+ // バージョンが取得できることを確認
11
+ expect(version).not.toBeNull();
12
+ expect(typeof version).toBe("string");
13
+
14
+ // セマンティックバージョニング形式かチェック(基本形式)
15
+ if (version) {
16
+ expect(version).toMatch(/^\d+\.\d+\.\d+/);
17
+ }
18
+ });
19
+
20
+ it("正常系: 取得したバージョンがpackage.jsonのversionと一致する", async () => {
21
+ const version = await getPackageVersion();
22
+
23
+ // package.jsonから直接読み取ってバージョンを確認
24
+ const packageJsonContent = await readFile(
25
+ path.resolve(process.cwd(), "package.json"),
26
+ "utf-8",
27
+ );
28
+ const packageJson = JSON.parse(packageJsonContent);
29
+
30
+ expect(version).toBe(packageJson.version);
31
+ });
32
+
33
+ it("正常系: プレリリースバージョンも正しく取得できる", async () => {
34
+ // 注: このテストは実際のpackage.jsonのバージョンがプレリリース形式の場合にパスする
35
+ // 現在のバージョンが通常バージョンの場合、このテストはスキップされる
36
+ const version = await getPackageVersion();
37
+
38
+ if (version && version.includes("-")) {
39
+ // プレリリース識別子を含むバージョン(例: "2.0.0-beta.1")
40
+ expect(version).toMatch(/^\d+\.\d+\.\d+-[a-zA-Z0-9.-]+/);
41
+ }
42
+ });
43
+ });
package/src/utils.ts ADDED
@@ -0,0 +1,60 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { access, readFile } from "fs/promises";
4
+ import { existsSync } from "fs";
5
+ export function getCurrentDirname(): string {
6
+ return path.dirname(fileURLToPath(import.meta.url));
7
+ }
8
+
9
+ export class AppError extends Error {
10
+ constructor(
11
+ message: string,
12
+ public cause?: unknown,
13
+ ) {
14
+ super(message);
15
+ this.name = "AppError";
16
+ }
17
+ }
18
+
19
+ export function setupExitHandlers(): void {
20
+ // Handle Ctrl+C gracefully
21
+ process.on("SIGINT", () => {
22
+ console.log("\n\n👋 Goodbye!");
23
+ process.exit(0);
24
+ });
25
+
26
+ // Handle other termination signals
27
+ process.on("SIGTERM", () => {
28
+ console.log("\n\n👋 Goodbye!");
29
+ process.exit(0);
30
+ });
31
+ }
32
+
33
+ export function handleUserCancel(error: unknown): never {
34
+ if (error && typeof error === "object" && "name" in error) {
35
+ if (error.name === "ExitPromptError" || error.name === "AbortPromptError") {
36
+ console.log("\n\n👋 Operation cancelled. Goodbye!");
37
+ process.exit(0);
38
+ }
39
+ }
40
+ throw error;
41
+ }
42
+
43
+ interface PackageJson {
44
+ version: string;
45
+ name?: string;
46
+ }
47
+
48
+ export async function getPackageVersion(): Promise<string | null> {
49
+ try {
50
+ const currentDir = getCurrentDirname();
51
+ const packageJsonPath = path.resolve(currentDir, "..", "package.json");
52
+
53
+ const packageJsonContent = await readFile(packageJsonPath, "utf-8");
54
+ const packageJson: PackageJson = JSON.parse(packageJsonContent);
55
+
56
+ return packageJson.version || null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>gwt - Web UI</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,231 @@
1
+ import React, { useMemo } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import type { Branch } from "../../../../types/api.js";
4
+
5
+ const UNKNOWN_BASE = "__unknown__";
6
+
7
+ interface Lane {
8
+ id: string;
9
+ baseLabel: string;
10
+ baseNode: Branch | null;
11
+ nodes: Branch[];
12
+ isSyntheticBase: boolean;
13
+ }
14
+
15
+ interface BranchGraphProps {
16
+ branches: Branch[];
17
+ }
18
+
19
+ function formatBranchLabel(branch: Branch): string {
20
+ return branch.name.length > 32
21
+ ? `${branch.name.slice(0, 29)}...`
22
+ : branch.name;
23
+ }
24
+
25
+ function getDivergenceLabel(branch: Branch): string {
26
+ if (!branch.divergence) {
27
+ return "divergence: n/a";
28
+ }
29
+ const { ahead, behind, upToDate } = branch.divergence;
30
+ if (upToDate) {
31
+ return "divergence: up-to-date";
32
+ }
33
+ return `divergence: +${ahead} / -${behind}`;
34
+ }
35
+
36
+ export function BranchGraph({ branches }: BranchGraphProps) {
37
+ const branchMap = useMemo(() => {
38
+ return new Map(branches.map((branch) => [branch.name, branch]));
39
+ }, [branches]);
40
+
41
+ const referencedBases = useMemo(() => {
42
+ const baseSet = new Set<string>();
43
+ branches.forEach((branch) => {
44
+ if (branch.baseBranch) {
45
+ baseSet.add(branch.baseBranch);
46
+ }
47
+ });
48
+ return baseSet;
49
+ }, [branches]);
50
+
51
+ const lanes = useMemo<Lane[]>(() => {
52
+ const laneMap = new Map<string, Lane>();
53
+
54
+ branches.forEach((branch) => {
55
+ const base = branch.baseBranch ?? UNKNOWN_BASE;
56
+
57
+ if (!branch.baseBranch && referencedBases.has(branch.name)) {
58
+ // ベースとして参照されている場合は、グラフ上で基点ノードとしてのみ表示
59
+ return;
60
+ }
61
+
62
+ if (!laneMap.has(base)) {
63
+ const baseNode =
64
+ base !== UNKNOWN_BASE ? branchMap.get(base) ?? null : null;
65
+ laneMap.set(base, {
66
+ id: base,
67
+ baseLabel: base === UNKNOWN_BASE ? "ベース不明" : base,
68
+ baseNode,
69
+ nodes: [],
70
+ isSyntheticBase: baseNode === null,
71
+ });
72
+ }
73
+
74
+ laneMap.get(base)!.nodes.push(branch);
75
+ });
76
+
77
+ return Array.from(laneMap.values()).sort((a, b) => {
78
+ if (a.id === UNKNOWN_BASE) {
79
+ return 1;
80
+ }
81
+ if (b.id === UNKNOWN_BASE) {
82
+ return -1;
83
+ }
84
+ return a.baseLabel.localeCompare(b.baseLabel, "ja");
85
+ });
86
+ }, [branches, branchMap, referencedBases]);
87
+
88
+ if (!lanes.length) {
89
+ return (
90
+ <section className="branch-graph-panel">
91
+ <div className="branch-graph-panel__empty">
92
+ <p>グラフ表示できるブランチがありません。</p>
93
+ <p>fetch済みのブランチやWorktreeを追加すると関係図が表示されます。</p>
94
+ </div>
95
+ </section>
96
+ );
97
+ }
98
+
99
+ return (
100
+ <section className="branch-graph-panel">
101
+ <header className="branch-graph-panel__header">
102
+ <div>
103
+ <p className="branch-graph-panel__eyebrow">BRANCH GRAPH</p>
104
+ <h2>ベースブランチの関係をグラフィカルに把握</h2>
105
+ <p>
106
+ baseRef、Git upstream、merge-baseヒューリスティクスを用いて推定したベースブランチ単位で
107
+ 派生ノードをレーン表示します。
108
+ </p>
109
+ </div>
110
+ <div className="branch-graph-panel__legend">
111
+ <span className="graph-chip graph-chip--base">Base</span>
112
+ <span className="graph-chip graph-chip--local">Local</span>
113
+ <span className="graph-chip graph-chip--remote">Remote</span>
114
+ <span className="graph-chip graph-chip--worktree">Worktree</span>
115
+ </div>
116
+ </header>
117
+
118
+ <div className="branch-graph">
119
+ {lanes.map((lane) => (
120
+ <article className="branch-graph__lane" key={lane.id}>
121
+ <div className="branch-graph__lane-heading">
122
+ <p className="branch-graph__lane-label">
123
+ {lane.baseLabel}
124
+ {lane.baseNode && (
125
+ <span className="branch-graph__lane-meta">
126
+ {lane.baseNode.type === "local" ? "LOCAL" : "REMOTE"}
127
+ </span>
128
+ )}
129
+ {lane.isSyntheticBase && (
130
+ <span className="branch-graph__lane-meta lane-meta--muted">
131
+ 推定のみ
132
+ </span>
133
+ )}
134
+ </p>
135
+ <span className="branch-graph__lane-count">
136
+ {lane.nodes.length} branch
137
+ {lane.nodes.length > 1 ? "es" : ""}
138
+ </span>
139
+ </div>
140
+
141
+ <div className="branch-graph__track">
142
+ {renderBaseNode(lane)}
143
+ {lane.nodes.map((branch) => (
144
+ <BranchNode key={branch.name} branch={branch} />
145
+ ))}
146
+ </div>
147
+ </article>
148
+ ))}
149
+ </div>
150
+ </section>
151
+ );
152
+ }
153
+
154
+ function renderBaseNode(lane: Lane) {
155
+ const label =
156
+ lane.baseLabel === "ベース不明" ? "Unknown base" : lane.baseLabel;
157
+ const content = (
158
+ <div
159
+ className={`branch-graph__node branch-graph__node--base ${
160
+ lane.baseNode ? `branch-graph__node--${lane.baseNode.type}` : ""
161
+ }`}
162
+ >
163
+ <span className="branch-graph__node-label">{label}</span>
164
+ <span className="branch-graph__node-meta">BASE</span>
165
+ <div className="branch-graph__tooltip">
166
+ <p>{label}</p>
167
+ <p>
168
+ {lane.baseNode
169
+ ? `type: ${lane.baseNode.type}`
170
+ : "推定されたベースブランチ"}
171
+ </p>
172
+ </div>
173
+ </div>
174
+ );
175
+
176
+ if (lane.baseNode) {
177
+ return (
178
+ <Link
179
+ key={`base-${lane.id}`}
180
+ to={`/${encodeURIComponent(lane.baseNode.name)}`}
181
+ className="branch-graph__node-link"
182
+ aria-label={`ベースブランチ ${lane.baseNode.name} を開く`}
183
+ >
184
+ {content}
185
+ </Link>
186
+ );
187
+ }
188
+
189
+ return (
190
+ <div key={`base-${lane.id}`} className="branch-graph__node-link">
191
+ {content}
192
+ </div>
193
+ );
194
+ }
195
+
196
+ function BranchNode({ branch }: { branch: Branch }) {
197
+ const node = (
198
+ <div
199
+ className={`branch-graph__node branch-graph__node--${branch.type} ${
200
+ branch.mergeStatus === "merged"
201
+ ? "branch-graph__node--merged"
202
+ : branch.mergeStatus === "unmerged"
203
+ ? "branch-graph__node--active"
204
+ : ""
205
+ }`}
206
+ >
207
+ <span className="branch-graph__node-label">
208
+ {formatBranchLabel(branch)}
209
+ </span>
210
+ <span className="branch-graph__node-meta">
211
+ {branch.worktreePath ? "Worktree" : "No Worktree"}
212
+ </span>
213
+ <div className="branch-graph__tooltip">
214
+ <p>{branch.name}</p>
215
+ <p>base: {branch.baseBranch ?? "unknown"}</p>
216
+ <p>{getDivergenceLabel(branch)}</p>
217
+ <p>{branch.worktreePath ?? "Worktree未作成"}</p>
218
+ </div>
219
+ </div>
220
+ );
221
+
222
+ return (
223
+ <Link
224
+ to={`/${encodeURIComponent(branch.name)}`}
225
+ className="branch-graph__node-link"
226
+ aria-label={`${branch.name} の詳細を開く`}
227
+ >
228
+ {node}
229
+ </Link>
230
+ );
231
+ }