@fgv/ts-res-browser 1.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 (95) hide show
  1. package/.rush/temp/5fc90bc7c2ccf812114ea099111568e5429be2a2.tar.log +15 -0
  2. package/.rush/temp/chunked-rush-logs/ts-res-browser.build.chunks.jsonl +35 -0
  3. package/.rush/temp/operation/build/all.log +35 -0
  4. package/.rush/temp/operation/build/log-chunks.jsonl +35 -0
  5. package/.rush/temp/operation/build/state.json +3 -0
  6. package/.rush/temp/shrinkwrap-deps.json +1217 -0
  7. package/README.md +95 -0
  8. package/TS_RES_BROWSER_PLAN.md +689 -0
  9. package/babel.config.js +17 -0
  10. package/config/jest.config.json +13 -0
  11. package/config/rig.json +16 -0
  12. package/dist/bundle.js +2 -0
  13. package/dist/bundle.js.LICENSE.txt +62 -0
  14. package/dist/index.html +1 -0
  15. package/package.json +78 -0
  16. package/postcss.config.js +3 -0
  17. package/public/index.html +15 -0
  18. package/rush-logs/ts-res-browser.build.cache.log +3 -0
  19. package/rush-logs/ts-res-browser.build.log +35 -0
  20. package/src/App.tsx +153 -0
  21. package/src/cli.ts +44 -0
  22. package/src/components/common/FileImporter.tsx +138 -0
  23. package/src/components/common/NavigationWarningModal.tsx +62 -0
  24. package/src/components/layout/AppLayout.tsx +46 -0
  25. package/src/components/layout/Header.tsx +22 -0
  26. package/src/components/layout/MessagesWindow.tsx +108 -0
  27. package/src/components/layout/Sidebar.tsx +92 -0
  28. package/src/components/tools/CompiledBrowser.tsx +949 -0
  29. package/src/components/tools/ConfigurationTool.tsx +1402 -0
  30. package/src/components/tools/FilterTool.tsx +1027 -0
  31. package/src/components/tools/ImportTool.tsx +246 -0
  32. package/src/components/tools/ResolutionViewer.tsx +1672 -0
  33. package/src/components/tools/SourceBrowser.tsx +496 -0
  34. package/src/hooks/useAppState.ts +107 -0
  35. package/src/hooks/useFileImport.ts +86 -0
  36. package/src/hooks/useNavigationWarning.ts +67 -0
  37. package/src/hooks/useResourceManager.ts +244 -0
  38. package/src/index.css +38 -0
  39. package/src/main.tsx +16 -0
  40. package/src/types/app.ts +62 -0
  41. package/src/types/fileSystemAccess.d.ts +94 -0
  42. package/src/utils/browserFileTreeAccessors.ts +245 -0
  43. package/src/utils/fileImport.ts +334 -0
  44. package/src/utils/filterResources.ts +677 -0
  45. package/src/utils/tsResIntegration.ts +467 -0
  46. package/tailwind.config.js +21 -0
  47. package/test-data/README.md +184 -0
  48. package/test-data/config-variations/README.md +64 -0
  49. package/test-data/config-variations/composition-terminology.json +112 -0
  50. package/test-data/config-variations/enterprise-complex.json +129 -0
  51. package/test-data/config-variations/gaming-app.json +87 -0
  52. package/test-data/config-variations/high-priority-language.json +65 -0
  53. package/test-data/config-variations/minimal-basic.json +37 -0
  54. package/test-data/config-variations/qualifier-default-values.json +71 -0
  55. package/test-data/config-variations/territory-first.json +68 -0
  56. package/test-data/config-variations/territory-hierarchy-test.json +31 -0
  57. package/test-data/config-variations/territory-hierarchy-unconstrained.json +32 -0
  58. package/test-data/default-matching-test/greeting.res.json +83 -0
  59. package/test-data/default-matching-test/only-defaults.res.json +48 -0
  60. package/test-data/sample-project/package.json +27 -0
  61. package/test-data/sample-project/src/resources/app-config.json +27 -0
  62. package/test-data/sample-project/src/resources/app.json +177 -0
  63. package/test-data/sample-project/src/resources/config/app.env=development.json +21 -0
  64. package/test-data/sample-project/src/resources/config/app.json +21 -0
  65. package/test-data/sample-project/src/resources/config/app.platform=mobile.json +23 -0
  66. package/test-data/sample-project/src/resources/feature-flags.json +23 -0
  67. package/test-data/sample-project/src/resources/home=CA/common.json +16 -0
  68. package/test-data/sample-project/src/resources/images/density=hdpi.json +29 -0
  69. package/test-data/sample-project/src/resources/images/home.json +29 -0
  70. package/test-data/sample-project/src/resources/images/logout.json +14 -0
  71. package/test-data/sample-project/src/resources/images/settings.json +14 -0
  72. package/test-data/sample-project/src/resources/images/ui-icons.json +29 -0
  73. package/test-data/sample-project/src/resources/images/user.density=hdpi.json +14 -0
  74. package/test-data/sample-project/src/resources/images/user.json +14 -0
  75. package/test-data/sample-project/src/resources/lang=en-US/common.json +15 -0
  76. package/test-data/sample-project/src/resources/language=en-CA/ui-terms.json +19 -0
  77. package/test-data/sample-project/src/resources/language=en-GB/ui-terms.json +30 -0
  78. package/test-data/sample-project/src/resources/language=fr-CA/ui-terms.json +25 -0
  79. package/test-data/sample-project/src/resources/language=nl-BE/ui-terms.json +17 -0
  80. package/test-data/sample-project/src/resources/region=europe/feature-flags.json +17 -0
  81. package/test-data/sample-project/src/resources/region=north-america/feature-flags.json +18 -0
  82. package/test-data/sample-project/src/resources/strings/dashboard.home=CA,language=fr.json +20 -0
  83. package/test-data/sample-project/src/resources/strings/dashboard.json +18 -0
  84. package/test-data/sample-project/src/resources/strings/dashboard.role=admin.json +23 -0
  85. package/test-data/sample-project/src/resources/strings/dashboard.role=user.json +21 -0
  86. package/test-data/sample-project/src/resources/strings/language=en-US.json +16 -0
  87. package/test-data/sample-project/src/resources/strings.json +78 -0
  88. package/test-data/sample-project/src/resources/territory=BE/app-config.json +19 -0
  89. package/test-data/sample-project/src/resources/territory=CA/app-config.json +21 -0
  90. package/test-data/sample-project/src/resources/territory=GB/app-config.json +25 -0
  91. package/test-data/sample-project/src/resources/territory=NL/app-config.json +25 -0
  92. package/test-data/sample-project/src/resources/ui-terms.json +96 -0
  93. package/test-data/sample-project/src/resources-config.json +111 -0
  94. package/tsconfig.json +27 -0
  95. package/webpack.config.js +54 -0
@@ -0,0 +1,245 @@
1
+ import { Result, succeed, fail, FileTree } from '@fgv/ts-utils';
2
+ import { ImportedDirectory, ImportedFile } from './fileImport';
3
+
4
+ /**
5
+ * Custom FileTreeAccessors implementation for browser environment
6
+ * that preserves directory structure for ts-res qualifier extraction
7
+ */
8
+ export class BrowserFileTreeAccessors implements FileTree.IFileTreeAccessors {
9
+ private readonly rootDirectory: ImportedDirectory;
10
+ private readonly pathSeparator = '/';
11
+
12
+ constructor(rootDirectory: ImportedDirectory) {
13
+ this.rootDirectory = rootDirectory;
14
+ }
15
+
16
+ public static create(rootDirectory: ImportedDirectory): Result<BrowserFileTreeAccessors> {
17
+ try {
18
+ return succeed(new BrowserFileTreeAccessors(rootDirectory));
19
+ } catch (error) {
20
+ return fail(
21
+ `Failed to create BrowserFileTreeAccessors: ${error instanceof Error ? error.message : String(error)}`
22
+ );
23
+ }
24
+ }
25
+
26
+ // Path manipulation methods (browser-compatible, no Node.js dependencies)
27
+ public resolveAbsolutePath(...paths: string[]): string {
28
+ // Start with root path or first absolute path
29
+ let result = '';
30
+
31
+ for (const path of paths) {
32
+ if (!path) continue;
33
+
34
+ if (path.startsWith('/')) {
35
+ // Absolute path - replace current result
36
+ result = path;
37
+ } else {
38
+ // Relative path - append to current result
39
+ result = this.joinPaths(result, path);
40
+ }
41
+ }
42
+
43
+ // Ensure it starts with / (absolute)
44
+ if (!result.startsWith('/')) {
45
+ result = '/' + result;
46
+ }
47
+
48
+ return this.normalizePath(result);
49
+ }
50
+
51
+ public getExtension(path: string): string {
52
+ const baseName = this.getBaseName(path);
53
+ const lastDot = baseName.lastIndexOf('.');
54
+ return lastDot === -1 ? '' : baseName.substring(lastDot);
55
+ }
56
+
57
+ public getBaseName(path: string, suffix?: string): string {
58
+ if (!path) return '';
59
+
60
+ // Remove trailing slashes
61
+ const normalizedPath = path.replace(/\/+$/, '');
62
+
63
+ // Get the last segment
64
+ const lastSlash = normalizedPath.lastIndexOf('/');
65
+ let baseName = lastSlash === -1 ? normalizedPath : normalizedPath.substring(lastSlash + 1);
66
+
67
+ // Remove suffix if provided
68
+ if (suffix && baseName.endsWith(suffix)) {
69
+ baseName = baseName.substring(0, baseName.length - suffix.length);
70
+ }
71
+
72
+ return baseName;
73
+ }
74
+
75
+ public joinPaths(...paths: string[]): string {
76
+ if (paths.length === 0) return '';
77
+
78
+ const parts: string[] = [];
79
+
80
+ for (const path of paths) {
81
+ if (!path) continue;
82
+
83
+ // Split by separator and filter empty parts
84
+ const pathParts = path.split(this.pathSeparator).filter((part) => part.length > 0);
85
+ parts.push(...pathParts);
86
+ }
87
+
88
+ return '/' + parts.join(this.pathSeparator);
89
+ }
90
+
91
+ // File system access methods
92
+ public getItem(path: string): Result<FileTree.FileTreeItem> {
93
+ try {
94
+ const absolutePath = this.ensureAbsolute(path);
95
+ const result = this.findItemAtPath(absolutePath);
96
+
97
+ if (!result) {
98
+ return fail(`Item not found at path: ${absolutePath}`);
99
+ }
100
+
101
+ if (result.type === 'file') {
102
+ return FileTree.FileItem.create(absolutePath, this);
103
+ } else {
104
+ return FileTree.DirectoryItem.create(absolutePath, this);
105
+ }
106
+ } catch (error) {
107
+ return fail(`Failed to get item: ${error instanceof Error ? error.message : String(error)}`);
108
+ }
109
+ }
110
+
111
+ public getFileContents(path: string): Result<string> {
112
+ try {
113
+ const absolutePath = this.ensureAbsolute(path);
114
+ const result = this.findItemAtPath(absolutePath);
115
+
116
+ if (!result) {
117
+ return fail(`File not found at path: ${absolutePath}`);
118
+ }
119
+
120
+ if (result.type !== 'file') {
121
+ return fail(`Path is not a file: ${absolutePath}`);
122
+ }
123
+
124
+ const file = result.item as ImportedFile;
125
+ return succeed(file.content);
126
+ } catch (error) {
127
+ return fail(`Failed to get file contents: ${error instanceof Error ? error.message : String(error)}`);
128
+ }
129
+ }
130
+
131
+ public getChildren(path: string): Result<ReadonlyArray<FileTree.FileTreeItem>> {
132
+ try {
133
+ const absolutePath = this.ensureAbsolute(path);
134
+ const result = this.findItemAtPath(absolutePath);
135
+
136
+ if (!result) {
137
+ return fail(`Directory not found at path: ${absolutePath}`);
138
+ }
139
+
140
+ if (result.type !== 'directory') {
141
+ return fail(`Path is not a directory: ${absolutePath}`);
142
+ }
143
+
144
+ const directory = result.item as ImportedDirectory;
145
+ const children: FileTree.FileTreeItem[] = [];
146
+
147
+ // Add files
148
+ for (const file of directory.files) {
149
+ const childPath = this.joinPaths(absolutePath, file.name);
150
+ const fileItemResult = FileTree.FileItem.create(childPath, this);
151
+
152
+ if (fileItemResult.isSuccess()) {
153
+ children.push(fileItemResult.value);
154
+ }
155
+ }
156
+
157
+ // Add subdirectories
158
+ for (const subDirectory of directory.directories) {
159
+ const childPath = this.joinPaths(absolutePath, subDirectory.name);
160
+ const dirItemResult = FileTree.DirectoryItem.create(childPath, this);
161
+
162
+ if (dirItemResult.isSuccess()) {
163
+ children.push(dirItemResult.value);
164
+ }
165
+ }
166
+
167
+ return succeed(children);
168
+ } catch (error) {
169
+ return fail(`Failed to get children: ${error instanceof Error ? error.message : String(error)}`);
170
+ }
171
+ }
172
+
173
+ // Helper methods
174
+ private ensureAbsolute(path: string): string {
175
+ return path.startsWith('/') ? path : '/' + path;
176
+ }
177
+
178
+ private normalizePath(path: string): string {
179
+ if (!path) return '/';
180
+
181
+ // Split into parts, filter empty ones, and handle . and ..
182
+ const parts = path.split('/').filter((part) => part.length > 0 && part !== '.');
183
+ const normalized: string[] = [];
184
+
185
+ for (const part of parts) {
186
+ if (part === '..') {
187
+ if (normalized.length > 0) {
188
+ normalized.pop();
189
+ }
190
+ } else {
191
+ normalized.push(part);
192
+ }
193
+ }
194
+
195
+ return '/' + normalized.join('/');
196
+ }
197
+
198
+ private findItemAtPath(
199
+ absolutePath: string
200
+ ): { item: ImportedDirectory | ImportedFile; type: 'file' | 'directory' } | null {
201
+ const normalizedPath = this.normalizePath(absolutePath);
202
+
203
+ // Handle root path
204
+ if (normalizedPath === '/' || normalizedPath === '') {
205
+ return { item: this.rootDirectory, type: 'directory' };
206
+ }
207
+
208
+ // Split path into segments (excluding root slash)
209
+ const segments = normalizedPath
210
+ .substring(1)
211
+ .split('/')
212
+ .filter((seg) => seg.length > 0);
213
+
214
+ let currentDir = this.rootDirectory;
215
+
216
+ // Navigate through directory segments
217
+ for (let i = 0; i < segments.length - 1; i++) {
218
+ const segment = segments[i];
219
+ const subDir = currentDir.directories.find((dir) => dir.name === segment);
220
+
221
+ if (!subDir) {
222
+ return null;
223
+ }
224
+
225
+ currentDir = subDir;
226
+ }
227
+
228
+ // Get the final item (file or directory)
229
+ const finalSegment = segments[segments.length - 1];
230
+
231
+ // Check if it's a file
232
+ const file = currentDir.files.find((f) => f.name === finalSegment);
233
+ if (file) {
234
+ return { item: file, type: 'file' };
235
+ }
236
+
237
+ // Check if it's a directory
238
+ const directory = currentDir.directories.find((d) => d.name === finalSegment);
239
+ if (directory) {
240
+ return { item: directory, type: 'directory' };
241
+ }
242
+
243
+ return null;
244
+ }
245
+ }
@@ -0,0 +1,334 @@
1
+ import { Result, succeed, fail } from '@fgv/ts-utils';
2
+
3
+ export interface ImportedFile {
4
+ name: string;
5
+ path: string;
6
+ content: string;
7
+ handle?: FileSystemFileHandle;
8
+ }
9
+
10
+ export interface ImportedDirectory {
11
+ name: string;
12
+ path: string;
13
+ files: ImportedFile[];
14
+ directories: ImportedDirectory[];
15
+ }
16
+
17
+ export interface FileImportOptions {
18
+ acceptedTypes?: string[];
19
+ multiple?: boolean;
20
+ includeDirectories?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Check if File System Access API is supported
25
+ */
26
+ export function isFileSystemAccessSupported(): boolean {
27
+ return 'showDirectoryPicker' in window && 'showOpenFilePicker' in window;
28
+ }
29
+
30
+ /**
31
+ * Modern File System Access API implementation
32
+ */
33
+ export class ModernFileImporter {
34
+ /**
35
+ * Pick a directory using File System Access API
36
+ */
37
+ async pickDirectory(): Promise<Result<ImportedDirectory>> {
38
+ try {
39
+ if (!isFileSystemAccessSupported()) {
40
+ return fail('File System Access API not supported');
41
+ }
42
+
43
+ const directoryHandle = await window.showDirectoryPicker({
44
+ mode: 'read',
45
+ startIn: 'documents'
46
+ });
47
+
48
+ const importedDirectory = await this.processDirectoryHandle(directoryHandle);
49
+ return succeed(importedDirectory);
50
+ } catch (error) {
51
+ if (error instanceof DOMException && error.name === 'AbortError') {
52
+ return fail('Directory selection was cancelled');
53
+ }
54
+ return fail(`Failed to pick directory: ${error instanceof Error ? error.message : String(error)}`);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Pick files using File System Access API
60
+ */
61
+ async pickFiles(options: FileImportOptions = {}): Promise<Result<ImportedFile[]>> {
62
+ try {
63
+ if (!isFileSystemAccessSupported()) {
64
+ return fail('File System Access API not supported');
65
+ }
66
+
67
+ const fileHandles = await window.showOpenFilePicker({
68
+ multiple: options.multiple ?? true,
69
+ types: options.acceptedTypes
70
+ ? [
71
+ {
72
+ description: 'Resource files',
73
+ accept: {
74
+ 'application/json': options.acceptedTypes
75
+ }
76
+ }
77
+ ]
78
+ : undefined,
79
+ excludeAcceptAllOption: false
80
+ });
81
+
82
+ const importedFiles: ImportedFile[] = [];
83
+ for (const fileHandle of fileHandles) {
84
+ const result = await this.processFileHandle(fileHandle);
85
+ if (result.isSuccess()) {
86
+ importedFiles.push(result.value);
87
+ }
88
+ }
89
+
90
+ return succeed(importedFiles);
91
+ } catch (error) {
92
+ if (error instanceof DOMException && error.name === 'AbortError') {
93
+ return fail('File selection was cancelled');
94
+ }
95
+ return fail(`Failed to pick files: ${error instanceof Error ? error.message : String(error)}`);
96
+ }
97
+ }
98
+
99
+ private async processDirectoryHandle(
100
+ directoryHandle: FileSystemDirectoryHandle,
101
+ basePath = ''
102
+ ): Promise<ImportedDirectory> {
103
+ const files: ImportedFile[] = [];
104
+ const directories: ImportedDirectory[] = [];
105
+ const currentPath = basePath ? `${basePath}/${directoryHandle.name}` : directoryHandle.name;
106
+
107
+ for await (const [name, handle] of directoryHandle.entries()) {
108
+ if (handle.kind === 'file') {
109
+ const result = await this.processFileHandle(handle as FileSystemFileHandle, currentPath);
110
+ if (result.isSuccess()) {
111
+ files.push(result.value);
112
+ }
113
+ } else if (handle.kind === 'directory') {
114
+ const subDirectory = await this.processDirectoryHandle(
115
+ handle as FileSystemDirectoryHandle,
116
+ currentPath
117
+ );
118
+ directories.push(subDirectory);
119
+ }
120
+ }
121
+
122
+ return {
123
+ name: directoryHandle.name,
124
+ path: currentPath,
125
+ files,
126
+ directories
127
+ };
128
+ }
129
+
130
+ private async processFileHandle(
131
+ fileHandle: FileSystemFileHandle,
132
+ basePath = ''
133
+ ): Promise<Result<ImportedFile>> {
134
+ try {
135
+ const file = await fileHandle.getFile();
136
+ const content = await file.text();
137
+ const filePath = basePath ? `${basePath}/${file.name}` : file.name;
138
+
139
+ return succeed({
140
+ name: file.name,
141
+ path: filePath,
142
+ content,
143
+ handle: fileHandle
144
+ });
145
+ } catch (error) {
146
+ return fail(
147
+ `Failed to read file ${fileHandle.name}: ${error instanceof Error ? error.message : String(error)}`
148
+ );
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Fallback implementation using traditional file input
155
+ */
156
+ export class FallbackFileImporter {
157
+ /**
158
+ * Pick a directory using webkitdirectory attribute
159
+ */
160
+ async pickDirectory(): Promise<Result<ImportedDirectory>> {
161
+ return new Promise((resolve) => {
162
+ const input = document.createElement('input');
163
+ input.type = 'file';
164
+ input.multiple = true;
165
+ input.webkitdirectory = true;
166
+
167
+ input.addEventListener('change', async (event) => {
168
+ const files = (event.target as HTMLInputElement).files;
169
+ if (!files || files.length === 0) {
170
+ resolve(fail('No directory selected'));
171
+ return;
172
+ }
173
+
174
+ try {
175
+ const importedDirectory = await this.processFileList(files);
176
+ resolve(succeed(importedDirectory));
177
+ } catch (error) {
178
+ resolve(
179
+ fail(`Failed to process directory: ${error instanceof Error ? error.message : String(error)}`)
180
+ );
181
+ }
182
+ });
183
+
184
+ input.addEventListener('cancel', () => {
185
+ resolve(fail('Directory selection was cancelled'));
186
+ });
187
+
188
+ input.click();
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Pick files using traditional file input
194
+ */
195
+ async pickFiles(options: FileImportOptions = {}): Promise<Result<ImportedFile[]>> {
196
+ return new Promise((resolve) => {
197
+ const input = document.createElement('input');
198
+ input.type = 'file';
199
+ input.multiple = options.multiple ?? true;
200
+
201
+ if (options.acceptedTypes) {
202
+ input.accept = options.acceptedTypes.join(',');
203
+ }
204
+
205
+ input.addEventListener('change', async (event) => {
206
+ const files = (event.target as HTMLInputElement).files;
207
+ if (!files || files.length === 0) {
208
+ resolve(fail('No files selected'));
209
+ return;
210
+ }
211
+
212
+ try {
213
+ const importedFiles: ImportedFile[] = [];
214
+ for (const file of Array.from(files)) {
215
+ const content = await file.text();
216
+ importedFiles.push({
217
+ name: file.name,
218
+ path: file.name,
219
+ content
220
+ });
221
+ }
222
+ resolve(succeed(importedFiles));
223
+ } catch (error) {
224
+ resolve(fail(`Failed to read files: ${error instanceof Error ? error.message : String(error)}`));
225
+ }
226
+ });
227
+
228
+ input.addEventListener('cancel', () => {
229
+ resolve(fail('File selection was cancelled'));
230
+ });
231
+
232
+ input.click();
233
+ });
234
+ }
235
+
236
+ private async processFileList(files: FileList): Promise<ImportedDirectory> {
237
+ const fileMap = new Map<string, ImportedFile[]>();
238
+ const dirMap = new Map<string, ImportedDirectory>();
239
+
240
+ // Process all files
241
+ for (const file of Array.from(files)) {
242
+ const content = await file.text();
243
+ const pathParts = file.webkitRelativePath.split('/');
244
+ const fileName = pathParts[pathParts.length - 1];
245
+ const dirPath = pathParts.slice(0, -1).join('/');
246
+
247
+ const importedFile: ImportedFile = {
248
+ name: fileName,
249
+ path: file.webkitRelativePath,
250
+ content
251
+ };
252
+
253
+ if (!fileMap.has(dirPath)) {
254
+ fileMap.set(dirPath, []);
255
+ }
256
+ fileMap.get(dirPath)!.push(importedFile);
257
+ }
258
+
259
+ // Build directory structure
260
+ const rootDirName = files[0].webkitRelativePath.split('/')[0];
261
+ return this.buildDirectoryTree(rootDirName, '', fileMap, dirMap);
262
+ }
263
+
264
+ private buildDirectoryTree(
265
+ name: string,
266
+ path: string,
267
+ fileMap: Map<string, ImportedFile[]>,
268
+ dirMap: Map<string, ImportedDirectory>
269
+ ): ImportedDirectory {
270
+ const currentPath = path ? `${path}/${name}` : name;
271
+ const files = fileMap.get(currentPath) || [];
272
+ const directories: ImportedDirectory[] = [];
273
+
274
+ // Find subdirectories
275
+ for (const [filePath] of fileMap) {
276
+ if (filePath.startsWith(currentPath + '/')) {
277
+ const relativePath = filePath.substring(currentPath.length + 1);
278
+ const nextDirName = relativePath.split('/')[0];
279
+
280
+ if (relativePath.includes('/') && !directories.some((d) => d.name === nextDirName)) {
281
+ const subDir = this.buildDirectoryTree(nextDirName, currentPath, fileMap, dirMap);
282
+ directories.push(subDir);
283
+ }
284
+ }
285
+ }
286
+
287
+ return {
288
+ name,
289
+ path: currentPath,
290
+ files,
291
+ directories
292
+ };
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Universal file importer that automatically selects the best available method
298
+ */
299
+ export class FileImporter {
300
+ private modernImporter = new ModernFileImporter();
301
+ private fallbackImporter = new FallbackFileImporter();
302
+
303
+ /**
304
+ * Pick a directory using the best available method
305
+ */
306
+ async pickDirectory(): Promise<Result<ImportedDirectory>> {
307
+ if (isFileSystemAccessSupported()) {
308
+ return this.modernImporter.pickDirectory();
309
+ } else {
310
+ return this.fallbackImporter.pickDirectory();
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Pick files using the best available method
316
+ */
317
+ async pickFiles(options: FileImportOptions = {}): Promise<Result<ImportedFile[]>> {
318
+ if (isFileSystemAccessSupported()) {
319
+ return this.modernImporter.pickFiles(options);
320
+ } else {
321
+ return this.fallbackImporter.pickFiles(options);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Get the current import method being used
327
+ */
328
+ getCurrentMethod(): 'modern' | 'fallback' {
329
+ return isFileSystemAccessSupported() ? 'modern' : 'fallback';
330
+ }
331
+ }
332
+
333
+ // Export singleton instance
334
+ export const fileImporter = new FileImporter();