emoemu 0.1.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 (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. package/vitest.config.ts +13 -0
@@ -0,0 +1,781 @@
1
+ /**
2
+ * Add ROMs Prompt Component
3
+ *
4
+ * Shared component for adding ROMs to the library. Used both when no playlists
5
+ * exist (initial setup) and from the ROM browser's "Add ROMs" action.
6
+ */
7
+
8
+ import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
9
+ import { Box, Text, useInput, useApp } from 'ink';
10
+ import { Spinner, ProgressBar, TextInput } from '@inkjs/ui';
11
+ import { readdirSync, statSync } from 'fs';
12
+ import { resolve, dirname, basename, join } from 'path';
13
+ import { ScanCancelledError, validateRomFile } from '../../frontend/romScanner';
14
+ import { getSupportedSystems, getSupportedExtensions } from '../../frontend/coreRegistry';
15
+ import type { ScanProgress } from '../../frontend/romScanner';
16
+ import { useGamepadContext } from '../GamepadContext';
17
+ import {
18
+ findPlaylistsForDirectory,
19
+ generatePlaylistsBySystem,
20
+ buildPlaylistIndex,
21
+ normalizePath,
22
+ analyzePlaylistSync,
23
+ syncPlaylists,
24
+ resolvePath,
25
+ } from '../../frontend/playlist';
26
+ import type { PlaylistInfo, DuplicateDecision } from '../../frontend/playlist';
27
+ import { showDuplicateCrcPrompt } from '../DuplicateCrcPrompt';
28
+ import {
29
+ PROGRESS_FULL,
30
+ PROGRESS_MULTIPLIER,
31
+ MAX_PATH_SUGGESTIONS,
32
+ MENU_DIRECTORY,
33
+ MENU_IMPORT,
34
+ MENU_EXIT,
35
+ } from './consts';
36
+ import { getErrorMessage } from '../../utils/getErrorMessage';
37
+
38
+ export * from './consts';
39
+
40
+ export interface AddRomsPromptProps {
41
+ directory: string;
42
+ playlistDirectory: string;
43
+ scanDepth: number;
44
+ onPlaylistGenerated: (playlists: PlaylistInfo[]) => void;
45
+ onExit: () => void;
46
+ /** If true, automatically start importing without showing the prompt UI */
47
+ autoImport?: boolean;
48
+ /** If true, exit the entire app when cancel is pressed (default: false) */
49
+ exitAppOnCancel?: boolean;
50
+ }
51
+
52
+ /** System breakdown for import results */
53
+ interface SystemCount {
54
+ system: string;
55
+ count: number;
56
+ }
57
+
58
+ /**
59
+ * Get path suggestions based on current input path (directories and ROM files)
60
+ */
61
+ const getPathSuggestions = (inputPath: string): string[] => {
62
+ const supportedExtensions = new Set(getSupportedExtensions());
63
+
64
+ const isRomFile = (entry: string): boolean => {
65
+ const ext = '.' + entry.split('.').pop()?.toLowerCase();
66
+ return supportedExtensions.has(ext);
67
+ };
68
+
69
+ const isValidEntry = (fullPath: string): boolean => {
70
+ try {
71
+ const stat = statSync(fullPath);
72
+ if (stat.isDirectory()) {
73
+ return true;
74
+ }
75
+ // Only suggest ROM files
76
+ return stat.isFile() && isRomFile(fullPath);
77
+ } catch {
78
+ return false;
79
+ }
80
+ };
81
+
82
+ try {
83
+ const resolvedPath = resolve(inputPath);
84
+
85
+ // Check if the input path itself is a directory
86
+ try {
87
+ const stat = statSync(resolvedPath);
88
+ if (stat.isDirectory()) {
89
+ // List contents of this directory (directories first, then ROM files)
90
+ const entries = readdirSync(resolvedPath);
91
+ const dirs: string[] = [];
92
+ const files: string[] = [];
93
+
94
+ for (const entry of entries) {
95
+ if (entry.startsWith('.')) {continue;} // Hide hidden entries
96
+ const fullPath = join(resolvedPath, entry);
97
+ try {
98
+ const entryStat = statSync(fullPath);
99
+ if (entryStat.isDirectory()) {
100
+ dirs.push(fullPath);
101
+ } else if (entryStat.isFile() && isRomFile(entry)) {
102
+ files.push(fullPath);
103
+ }
104
+ } catch {
105
+ // Skip inaccessible entries
106
+ }
107
+ }
108
+
109
+ // Return directories first, then ROM files
110
+ return [...dirs, ...files].slice(0, MAX_PATH_SUGGESTIONS);
111
+ }
112
+ } catch {
113
+ // Path doesn't exist as-is, try parent directory
114
+ }
115
+
116
+ // Get the parent directory and filter by basename prefix
117
+ const parentDir = dirname(resolvedPath);
118
+ const prefix = basename(resolvedPath).toLowerCase();
119
+
120
+ try {
121
+ const entries = readdirSync(parentDir);
122
+ const dirs: string[] = [];
123
+ const files: string[] = [];
124
+
125
+ for (const entry of entries) {
126
+ if (entry.startsWith('.')) {continue;} // Hide hidden entries
127
+ if (!entry.toLowerCase().startsWith(prefix)) {continue;}
128
+ const fullPath = join(parentDir, entry);
129
+ if (isValidEntry(fullPath)) {
130
+ try {
131
+ const stat = statSync(fullPath);
132
+ if (stat.isDirectory()) {
133
+ dirs.push(fullPath);
134
+ } else {
135
+ files.push(fullPath);
136
+ }
137
+ } catch {
138
+ // Skip
139
+ }
140
+ }
141
+ }
142
+
143
+ return [...dirs, ...files].slice(0, MAX_PATH_SUGGESTIONS);
144
+ } catch {
145
+ return [];
146
+ }
147
+ } catch {
148
+ return [];
149
+ }
150
+ };
151
+
152
+ /** Result of the import operation */
153
+ interface ImportResult {
154
+ totalFiles: number;
155
+ romsFound: number;
156
+ romsAdded: number;
157
+ alreadyInLibrary: number;
158
+ filesSkipped: number;
159
+ /** Number of playlist entries removed (files no longer exist) */
160
+ removed: number;
161
+ /** Number of entries updated (moved ROMs with path changes) */
162
+ moved: number;
163
+ /** Number of duplicate entries where path was updated */
164
+ duplicatesUpdated: number;
165
+ /** Number of duplicate entries that were skipped */
166
+ duplicatesSkipped: number;
167
+ playlists: PlaylistInfo[];
168
+ systems: SystemCount[];
169
+ }
170
+
171
+ export const AddRomsPrompt = ({
172
+ directory,
173
+ playlistDirectory,
174
+ scanDepth,
175
+ onPlaylistGenerated,
176
+ onExit,
177
+ autoImport = false,
178
+ exitAppOnCancel = false,
179
+ }: AddRomsPromptProps) => {
180
+ const { exit } = useApp();
181
+ const [selectedPath, setSelectedDirectory] = useState(directory);
182
+ const [inputKey, setInputKey] = useState(0); // Key to force TextInput re-render
183
+ const [hasTyped, setHasTyped] = useState(false); // Track if user has typed in current session
184
+ const [selectedIndex, setSelectedIndex] = useState(MENU_IMPORT); // Default to Add to Library
185
+ const [isGenerating, setIsGenerating] = useState(false);
186
+ const [progress, setProgress] = useState<ScanProgress | null>(null);
187
+ const [importResult, setImportResult] = useState<ImportResult | null>(null);
188
+ const [error, setError] = useState<string | null>(null);
189
+ const abortControllerRef = useRef<AbortController | null>(null);
190
+ const progressRef = useRef<ScanProgress | null>(null);
191
+
192
+ // Get path suggestions based on current input (only if user has typed)
193
+ const pathSuggestions = useMemo(
194
+ () => hasTyped ? getPathSuggestions(selectedPath) : [],
195
+ [selectedPath, hasTyped]
196
+ );
197
+
198
+ // Handle directory input changes
199
+ const handlePathChange = useCallback((value: string) => {
200
+ setSelectedDirectory(value);
201
+ setHasTyped(true);
202
+ }, []);
203
+
204
+ const isEditingPath = selectedIndex === MENU_DIRECTORY;
205
+
206
+ const cancelImport = useCallback(() => {
207
+ if (abortControllerRef.current) {
208
+ abortControllerRef.current.abort();
209
+ abortControllerRef.current = null;
210
+ }
211
+ }, []);
212
+
213
+ const handleGenerate = useCallback(async () => {
214
+ const targetPath = resolve(selectedPath);
215
+
216
+ // Check if path exists
217
+ let pathStat: ReturnType<typeof statSync>;
218
+ try {
219
+ pathStat = statSync(targetPath);
220
+ } catch {
221
+ setError('Path does not exist');
222
+ return;
223
+ }
224
+
225
+ setIsGenerating(true);
226
+ setError(null);
227
+ setProgress(null);
228
+ setImportResult(null);
229
+
230
+ // Build playlist index to check for existing ROMs
231
+ const playlistIndex = buildPlaylistIndex(playlistDirectory);
232
+
233
+ // Handle single file
234
+ if (pathStat.isFile()) {
235
+ const result = validateRomFile(targetPath);
236
+ if (!result.valid) {
237
+ setError(result.message);
238
+ setIsGenerating(false);
239
+ return;
240
+ }
241
+
242
+ const rom = result.rom;
243
+
244
+ // Check if ROM is already in library
245
+ const normalizedPath = normalizePath(targetPath);
246
+ if (playlistIndex.has(normalizedPath)) {
247
+ // ROM already in library - show success with 0 added
248
+ const playlists = findPlaylistsForDirectory(dirname(targetPath), playlistDirectory);
249
+ setImportResult({
250
+ totalFiles: 1,
251
+ romsFound: 1,
252
+ romsAdded: 0,
253
+ alreadyInLibrary: 1,
254
+ filesSkipped: 0,
255
+ removed: 0,
256
+ moved: 0,
257
+ duplicatesUpdated: 0,
258
+ duplicatesSkipped: 0,
259
+ playlists,
260
+ systems: [{ system: rom.system, count: 1 }],
261
+ });
262
+ setIsGenerating(false);
263
+ return;
264
+ }
265
+
266
+ // Add single ROM to playlist
267
+ const results = generatePlaylistsBySystem([rom], playlistDirectory);
268
+
269
+ const failures = results.filter(r => !r.success);
270
+ if (failures.length > 0) {
271
+ setError(`Failed to add ROM to library: ${failures[0].error}`);
272
+ setIsGenerating(false);
273
+ return;
274
+ }
275
+
276
+ // Find playlists for the ROM's directory
277
+ const playlists = findPlaylistsForDirectory(dirname(targetPath), playlistDirectory);
278
+
279
+ setImportResult({
280
+ totalFiles: 1,
281
+ romsFound: 1,
282
+ romsAdded: 1,
283
+ alreadyInLibrary: 0,
284
+ filesSkipped: 0,
285
+ removed: 0,
286
+ moved: 0,
287
+ duplicatesUpdated: 0,
288
+ duplicatesSkipped: 0,
289
+ playlists,
290
+ systems: [{ system: rom.system, count: 1 }],
291
+ });
292
+ setIsGenerating(false);
293
+ return;
294
+ }
295
+
296
+ // Handle directory
297
+ if (!pathStat.isDirectory()) {
298
+ setError('Path is not a file or directory');
299
+ setIsGenerating(false);
300
+ return;
301
+ }
302
+
303
+ // Create new abort controller for this scan
304
+ abortControllerRef.current = new AbortController();
305
+ const { signal } = abortControllerRef.current;
306
+
307
+ try {
308
+ // Analyze the directory for sync needs (new ROMs, missing entries, moved files)
309
+ const analysis = await analyzePlaylistSync(
310
+ targetPath,
311
+ playlistDirectory,
312
+ scanDepth,
313
+ (scanProgress) => {
314
+ progressRef.current = scanProgress;
315
+ setProgress(scanProgress);
316
+ },
317
+ signal
318
+ );
319
+
320
+ // Calculate totals from ref (state may not be updated yet due to React batching)
321
+ const finalProgress = progressRef.current;
322
+ const totalFiles = finalProgress?.total ?? 0;
323
+ const romsFound = finalProgress?.romsFound ?? 0;
324
+ const alreadyInLibrary = romsFound - analysis.newRoms.length - analysis.movedRoms.length;
325
+
326
+ // Check if there are no ROMs at all and nothing in playlists to clean up
327
+ if (romsFound === 0 && analysis.missingEntries.length === 0 && analysis.newRoms.length === 0) {
328
+ setError('No supported ROMs found in this directory');
329
+ setIsGenerating(false);
330
+ return;
331
+ }
332
+
333
+ // If nothing needs to sync, show "all in library" message
334
+ if (!analysis.needsSync) {
335
+ const playlists = findPlaylistsForDirectory(targetPath, playlistDirectory);
336
+
337
+ // For "all in library" case, we don't show system breakdown since we only have counts
338
+ const systems: SystemCount[] = [];
339
+
340
+ setImportResult({
341
+ totalFiles,
342
+ romsFound,
343
+ romsAdded: 0,
344
+ alreadyInLibrary,
345
+ filesSkipped: totalFiles - romsFound,
346
+ removed: 0,
347
+ moved: 0,
348
+ duplicatesUpdated: 0,
349
+ duplicatesSkipped: 0,
350
+ playlists,
351
+ systems,
352
+ });
353
+ setIsGenerating(false);
354
+ return;
355
+ }
356
+
357
+ // Handle duplicate CRC ROMs by prompting the user
358
+ const duplicateDecisions: DuplicateDecision[] = [];
359
+ if (analysis.duplicateCrcRoms.length > 0) {
360
+ for (const duplicate of analysis.duplicateCrcRoms) {
361
+ const existingPath = resolvePath(
362
+ duplicate.existingEntry.entry.path,
363
+ dirname(duplicate.existingEntry.playlistPath)
364
+ );
365
+ const choice = await showDuplicateCrcPrompt({
366
+ newPath: duplicate.newRom.path,
367
+ existingPath,
368
+ label: duplicate.existingEntry.entry.label,
369
+ crc32: duplicate.crc32,
370
+ });
371
+ duplicateDecisions.push({ duplicate, choice });
372
+ }
373
+ }
374
+
375
+ // Apply the sync changes (add new, remove missing, update moved, handle duplicates)
376
+ const syncResult = syncPlaylists(analysis, targetPath, playlistDirectory, {}, duplicateDecisions);
377
+
378
+ if (!syncResult.success && syncResult.errors.length > 0) {
379
+ setError(`Sync completed with errors: ${syncResult.errors[0]}`);
380
+ // Continue to show results even with errors
381
+ }
382
+
383
+ // Find the updated playlists
384
+ const playlists = findPlaylistsForDirectory(targetPath, playlistDirectory);
385
+
386
+ // Count new ROMs by system
387
+ const systemMap = new Map<string, number>();
388
+ for (const rom of analysis.newRoms) {
389
+ const count = systemMap.get(rom.system) ?? 0;
390
+ systemMap.set(rom.system, count + 1);
391
+ }
392
+ const systems: SystemCount[] = Array.from(systemMap.entries())
393
+ .map(([system, count]) => ({ system, count }))
394
+ .sort((a, b) => b.count - a.count);
395
+
396
+ // Show completion summary
397
+ setImportResult({
398
+ totalFiles,
399
+ romsFound,
400
+ romsAdded: syncResult.added,
401
+ alreadyInLibrary,
402
+ filesSkipped: totalFiles - romsFound,
403
+ removed: syncResult.removed,
404
+ moved: syncResult.moved,
405
+ duplicatesUpdated: syncResult.duplicatesUpdated,
406
+ duplicatesSkipped: syncResult.duplicatesSkipped,
407
+ playlists,
408
+ systems,
409
+ });
410
+ setIsGenerating(false);
411
+ } catch (err) {
412
+ // If cancelled, just return to prompt without error
413
+ if (err instanceof ScanCancelledError) {
414
+ setIsGenerating(false);
415
+ return;
416
+ }
417
+ setError(getErrorMessage(err));
418
+ setIsGenerating(false);
419
+ }
420
+ }, [selectedPath, scanDepth, playlistDirectory]);
421
+
422
+ // Auto-trigger import when autoImport is true (CLI path provided)
423
+ useEffect(() => {
424
+ if (autoImport && !isGenerating && !importResult && !error) {
425
+ void handleGenerate();
426
+ }
427
+ }, [autoImport, handleGenerate, isGenerating, importResult, error]);
428
+
429
+ // Auto-continue when autoImport is true and import completes (skip summary screen)
430
+ useEffect(() => {
431
+ if (autoImport && importResult) {
432
+ onPlaylistGenerated(importResult.playlists);
433
+ exit(); // Close the Ink app so importDirectory can continue
434
+ }
435
+ }, [autoImport, importResult, onPlaylistGenerated, exit]);
436
+
437
+ useInput((input, key) => {
438
+ // Allow cancellation during import
439
+ if (isGenerating) {
440
+ if (key.escape) {
441
+ cancelImport();
442
+ }
443
+ return;
444
+ }
445
+
446
+ // Handle completion screen - any key to continue
447
+ if (importResult) {
448
+ onPlaylistGenerated(importResult.playlists);
449
+ return;
450
+ }
451
+
452
+ // When editing path, only handle navigation keys
453
+ if (isEditingPath) {
454
+ // Tab or Right arrow accepts the first suggestion
455
+ if (key.tab || key.rightArrow) {
456
+ if (pathSuggestions.length > 0) {
457
+ setSelectedDirectory(pathSuggestions[0]);
458
+ setInputKey(prev => prev + 1); // Force TextInput re-render
459
+ }
460
+ return;
461
+ }
462
+ // Enter accepts the user-typed value (not autocomplete) and moves to Import
463
+ if (key.return) {
464
+ setSelectedIndex(MENU_IMPORT);
465
+ setHasTyped(false);
466
+ setInputKey(prev => prev + 1); // Force TextInput re-render to clear suggestion highlight
467
+ return;
468
+ }
469
+ if (key.downArrow) {
470
+ setSelectedIndex(MENU_IMPORT);
471
+ setHasTyped(false);
472
+ return;
473
+ }
474
+ if (key.escape) {
475
+ setSelectedIndex(MENU_IMPORT);
476
+ setHasTyped(false);
477
+ return;
478
+ }
479
+ // Let TextInput handle all other input
480
+ return;
481
+ }
482
+
483
+ if (key.escape) {
484
+ onExit();
485
+ if (exitAppOnCancel) {
486
+ exit();
487
+ }
488
+ return;
489
+ }
490
+
491
+ if (key.upArrow) {
492
+ setSelectedIndex(prev => Math.max(MENU_DIRECTORY, prev - 1));
493
+ return;
494
+ }
495
+
496
+ if (key.downArrow) {
497
+ setSelectedIndex(prev => Math.min(MENU_EXIT, prev + 1));
498
+ return;
499
+ }
500
+
501
+ if (key.return || input === ' ') {
502
+ if (selectedIndex === MENU_IMPORT) {
503
+ void handleGenerate();
504
+ } else if (selectedIndex === MENU_EXIT) {
505
+ onExit();
506
+ if (exitAppOnCancel) {
507
+ exit();
508
+ }
509
+ }
510
+ }
511
+ });
512
+
513
+ useGamepadContext({
514
+ onUp: () => {
515
+ if (isGenerating || importResult || isEditingPath) {return;}
516
+ setSelectedIndex(prev => Math.max(MENU_DIRECTORY, prev - 1));
517
+ },
518
+ onDown: () => {
519
+ if (isGenerating || importResult) {return;}
520
+ if (isEditingPath) {
521
+ setSelectedIndex(MENU_IMPORT);
522
+ setHasTyped(false);
523
+ return;
524
+ }
525
+ setSelectedIndex(prev => Math.min(MENU_EXIT, prev + 1));
526
+ },
527
+ onConfirm: () => {
528
+ if (isGenerating || isEditingPath) {return;}
529
+ if (importResult) {
530
+ onPlaylistGenerated(importResult.playlists);
531
+ return;
532
+ }
533
+ if (selectedIndex === MENU_IMPORT) {
534
+ void handleGenerate();
535
+ } else if (selectedIndex === MENU_EXIT) {
536
+ onExit();
537
+ if (exitAppOnCancel) {
538
+ exit();
539
+ }
540
+ }
541
+ },
542
+ onCancel: () => {
543
+ // Allow cancellation during import
544
+ if (isGenerating) {
545
+ cancelImport();
546
+ return;
547
+ }
548
+ // Exit path editing mode
549
+ if (isEditingPath) {
550
+ setSelectedIndex(MENU_IMPORT);
551
+ setHasTyped(false);
552
+ return;
553
+ }
554
+ if (importResult) {
555
+ onPlaylistGenerated(importResult.playlists);
556
+ return;
557
+ }
558
+ onExit();
559
+ if (exitAppOnCancel) {
560
+ exit();
561
+ }
562
+ },
563
+ onStart: () => {
564
+ if (importResult) {
565
+ onPlaylistGenerated(importResult.playlists);
566
+ }
567
+ },
568
+ });
569
+
570
+ if (isGenerating) {
571
+ if (progress) {
572
+ // Show determinate progress when total is known, indeterminate otherwise
573
+ const totalCount = progress.total;
574
+ const hasTotalCount = totalCount !== undefined && totalCount > 0;
575
+ const progressPercent = hasTotalCount
576
+ ? Math.round((progress.processed / totalCount) * PROGRESS_MULTIPLIER)
577
+ : undefined;
578
+
579
+ return (
580
+ <Box flexDirection="column" padding={1}>
581
+ <Box marginBottom={1}>
582
+ <Text color="cyan">Looking for ROMs...</Text>
583
+ </Box>
584
+ {progressPercent !== undefined && (
585
+ <Box marginBottom={1}>
586
+ <ProgressBar value={progressPercent} />
587
+ </Box>
588
+ )}
589
+ <Box marginBottom={1}>
590
+ <Text color="gray">
591
+ {hasTotalCount
592
+ ? `${progress.processed} of ${totalCount} files checked`
593
+ : `${progress.processed} files checked`}
594
+ {progress.romsFound > 0 && <Text color="green"> ({progress.romsFound} ROMs found)</Text>}
595
+ </Text>
596
+ </Box>
597
+ <Box marginBottom={1}>
598
+ <Text color="gray" dimColor wrap="truncate-end">
599
+ {progress.currentFile}
600
+ </Text>
601
+ </Box>
602
+ <Box>
603
+ <Text color="gray" dimColor>
604
+ Press <Text color="yellow">ESC</Text> to cancel
605
+ </Text>
606
+ </Box>
607
+ </Box>
608
+ );
609
+ }
610
+ return (
611
+ <Box padding={1}>
612
+ <Spinner label="Preparing..." />
613
+ </Box>
614
+ );
615
+ }
616
+
617
+ // Show completion summary (skip when autoImport - useEffect will auto-continue)
618
+ if (importResult) {
619
+ // When autoImport is true, skip the summary - the useEffect will call onPlaylistGenerated
620
+ if (autoImport) {
621
+ return null;
622
+ }
623
+
624
+ const noChanges = importResult.romsAdded === 0 && importResult.removed === 0 && importResult.moved === 0 && importResult.duplicatesUpdated === 0;
625
+ const allAlreadyInLibrary = noChanges && importResult.alreadyInLibrary > 0;
626
+ const someAlreadyInLibrary = importResult.romsAdded > 0 && importResult.alreadyInLibrary > 0;
627
+ const hasChanges = importResult.romsAdded > 0 || importResult.removed > 0 || importResult.moved > 0 || importResult.duplicatesUpdated > 0;
628
+
629
+ return (
630
+ <Box flexDirection="column" padding={1}>
631
+ <Box marginBottom={1}>
632
+ {allAlreadyInLibrary ? (
633
+ <Text bold color="cyan">{'\u2714'} All Games in Library</Text>
634
+ ) : (
635
+ <Text bold color="green">{'\u2714'} Sync Complete</Text>
636
+ )}
637
+ </Box>
638
+ <Box marginBottom={1}>
639
+ <ProgressBar value={PROGRESS_FULL} />
640
+ </Box>
641
+ <Box flexDirection="column" marginBottom={1}>
642
+ {allAlreadyInLibrary ? (
643
+ <Text color="white">
644
+ All <Text color="cyan" bold>{importResult.alreadyInLibrary}</Text> ROM{importResult.alreadyInLibrary !== 1 ? 's' : ''} already in your library
645
+ </Text>
646
+ ) : (
647
+ <>
648
+ {importResult.romsAdded > 0 && (
649
+ <Text color="white">
650
+ <Text color="green" bold>{importResult.romsAdded}</Text> ROM{importResult.romsAdded !== 1 ? 's' : ''} detected
651
+ </Text>
652
+ )}
653
+ {importResult.removed > 0 && (
654
+ <Text color="white">
655
+ <Text color="yellow" bold>{importResult.removed}</Text> missing ROM{importResult.removed !== 1 ? 's' : ''} removed from library
656
+ </Text>
657
+ )}
658
+ {importResult.moved > 0 && (
659
+ <Text color="white">
660
+ <Text color="blue" bold>{importResult.moved}</Text> ROM{importResult.moved !== 1 ? 's' : ''} moved (paths updated)
661
+ </Text>
662
+ )}
663
+ {importResult.duplicatesUpdated > 0 && (
664
+ <Text color="white">
665
+ <Text color="magenta" bold>{importResult.duplicatesUpdated}</Text> duplicate ROM{importResult.duplicatesUpdated !== 1 ? 's' : ''} updated to new path
666
+ </Text>
667
+ )}
668
+ {importResult.duplicatesSkipped > 0 && (
669
+ <Text color="gray">
670
+ {importResult.duplicatesSkipped} duplicate{importResult.duplicatesSkipped !== 1 ? 's' : ''} skipped (kept existing)
671
+ </Text>
672
+ )}
673
+ {someAlreadyInLibrary && (
674
+ <Text color="gray">
675
+ {importResult.alreadyInLibrary} already in library (skipped)
676
+ </Text>
677
+ )}
678
+ </>
679
+ )}
680
+ {importResult.filesSkipped > 0 && (
681
+ <Text color="gray">
682
+ {importResult.filesSkipped} file{importResult.filesSkipped !== 1 ? 's' : ''} skipped (not recognized as ROMs)
683
+ </Text>
684
+ )}
685
+ </Box>
686
+ {importResult.systems.length > 0 && hasChanges && (
687
+ <Box flexDirection="column" marginBottom={1}>
688
+ <Text color="white" bold>ROMs found for:</Text>
689
+ {importResult.systems.map(({ system, count }) => (
690
+ <Text key={system} color="gray">
691
+ {' '}<Text color="cyan">{system}</Text>: {count} ROM{count !== 1 ? 's' : ''}
692
+ </Text>
693
+ ))}
694
+ </Box>
695
+ )}
696
+ <Box marginTop={1}>
697
+ <Text color="gray" dimColor>
698
+ Press any key to continue
699
+ </Text>
700
+ </Box>
701
+ </Box>
702
+ );
703
+ }
704
+
705
+ return (
706
+ <Box flexDirection="column" padding={1}>
707
+ <Box marginBottom={1}>
708
+ <Text bold color="cyan">{'\u{1F4C2}'} Add ROMs</Text>
709
+ </Box>
710
+
711
+ <Box flexDirection="column" marginBottom={1}>
712
+ {/* Path input */}
713
+ <Box flexDirection="column" marginBottom={1}>
714
+ <Box>
715
+ <Text color={isEditingPath ? 'cyan' : 'white'} bold={isEditingPath}>
716
+ {isEditingPath ? '\u25B6 ' : ' '}Path:{' '}
717
+ </Text>
718
+ <TextInput
719
+ key={inputKey}
720
+ defaultValue={selectedPath}
721
+ suggestions={pathSuggestions}
722
+ onChange={handlePathChange}
723
+ isDisabled={!isEditingPath}
724
+ />
725
+ </Box>
726
+ {isEditingPath && (
727
+ <Text color="gray" dimColor> Path to ROM file or directory</Text>
728
+ )}
729
+ </Box>
730
+
731
+ {/* Add to Library option */}
732
+ <Box flexDirection="column">
733
+ <Text
734
+ color={selectedIndex === MENU_IMPORT ? 'cyan' : 'white'}
735
+ bold={selectedIndex === MENU_IMPORT}
736
+ >
737
+ {selectedIndex === MENU_IMPORT ? '\u25B6 ' : ' '}Add to Library
738
+ </Text>
739
+ {selectedIndex === MENU_IMPORT && (
740
+ <Text color="gray" dimColor> Your ROM files will stay where they are</Text>
741
+ )}
742
+ </Box>
743
+
744
+ {/* Exit option */}
745
+ <Box flexDirection="column">
746
+ <Text
747
+ color={selectedIndex === MENU_EXIT ? 'red' : 'red'}
748
+ bold={selectedIndex === MENU_EXIT}
749
+ >
750
+ {selectedIndex === MENU_EXIT ? '\u25B6 ' : ' '}Cancel
751
+ </Text>
752
+ {selectedIndex === MENU_EXIT && (
753
+ <Text color="gray" dimColor> Return to ROM browser</Text>
754
+ )}
755
+ </Box>
756
+ </Box>
757
+
758
+ <Box marginBottom={1}>
759
+ <Text color="gray" dimColor>
760
+ Supported: {getSupportedSystems().join(', ')}
761
+ </Text>
762
+ </Box>
763
+
764
+ {error && (
765
+ <Box marginBottom={1}>
766
+ <Text color="red">{'\u2717'} {error}</Text>
767
+ </Box>
768
+ )}
769
+
770
+ <Box marginTop={1}>
771
+ <Text color="gray" dimColor>
772
+ {isEditingPath
773
+ ? 'Tab/\u2192: Autocomplete \u23CE: Confirm \u2193/ESC: Menu'
774
+ : '\u2191\u2193: Navigate \u23CE/A: Select ESC/B: Cancel'}
775
+ </Text>
776
+ </Box>
777
+ </Box>
778
+ );
779
+ };
780
+
781
+ export default AddRomsPrompt;