@anymux/ui-kit 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 (244) hide show
  1. package/dist/ExplorerLayout-CSIJd7N4.js +105 -0
  2. package/dist/ExplorerLayout-CSIJd7N4.js.map +1 -0
  3. package/dist/FileBrowserContext-B6jixa2j.js +11 -0
  4. package/dist/FileBrowserContext-B6jixa2j.js.map +1 -0
  5. package/dist/calendar-DSlrbHoj.js +761 -0
  6. package/dist/calendar-DSlrbHoj.js.map +1 -0
  7. package/dist/calendar.d.ts +3 -0
  8. package/dist/calendar.js +3 -0
  9. package/dist/contacts-DQXTZzHc.js +539 -0
  10. package/dist/contacts-DQXTZzHc.js.map +1 -0
  11. package/dist/contacts.d.ts +3 -0
  12. package/dist/contacts.js +3 -0
  13. package/dist/file-browser-m5atC3kF.js +6755 -0
  14. package/dist/file-browser-m5atC3kF.js.map +1 -0
  15. package/dist/file-browser.d.ts +11 -0
  16. package/dist/file-browser.js +9 -0
  17. package/dist/git-B55e6LL-.js +561 -0
  18. package/dist/git-B55e6LL-.js.map +1 -0
  19. package/dist/git.d.ts +2 -0
  20. package/dist/git.js +3 -0
  21. package/dist/iconMap-V4B8P-Uh.js +206 -0
  22. package/dist/iconMap-V4B8P-Uh.js.map +1 -0
  23. package/dist/icons-CIsIOZXR.js +0 -0
  24. package/dist/icons.d.ts +2 -0
  25. package/dist/icons.js +4 -0
  26. package/dist/index-BNmNIWBL.d.ts +71 -0
  27. package/dist/index-BNmNIWBL.d.ts.map +1 -0
  28. package/dist/index-Bryv_GCG.d.ts +1481 -0
  29. package/dist/index-Bryv_GCG.d.ts.map +1 -0
  30. package/dist/index-CuQIjSXs.d.ts +134 -0
  31. package/dist/index-CuQIjSXs.d.ts.map +1 -0
  32. package/dist/index-DSu19mq0.d.ts +153 -0
  33. package/dist/index-DSu19mq0.d.ts.map +1 -0
  34. package/dist/index-DmsyeHFr.d.ts +149 -0
  35. package/dist/index-DmsyeHFr.d.ts.map +1 -0
  36. package/dist/index-DxnJ8FYM.d.ts +17 -0
  37. package/dist/index-DxnJ8FYM.d.ts.map +1 -0
  38. package/dist/index-DzfY1Tok.d.ts +32 -0
  39. package/dist/index-DzfY1Tok.d.ts.map +1 -0
  40. package/dist/index-Ml_SgiKa.d.ts +1847 -0
  41. package/dist/index-Ml_SgiKa.d.ts.map +1 -0
  42. package/dist/index-kHr9udZD.d.ts +1025 -0
  43. package/dist/index-kHr9udZD.d.ts.map +1 -0
  44. package/dist/index.d.ts +11 -0
  45. package/dist/index.js +15 -0
  46. package/dist/layout-Ca_4r8ka.js +89 -0
  47. package/dist/layout-Ca_4r8ka.js.map +1 -0
  48. package/dist/layout.d.ts +2 -0
  49. package/dist/layout.js +5 -0
  50. package/dist/list-CxfT6hix.js +6831 -0
  51. package/dist/list-CxfT6hix.js.map +1 -0
  52. package/dist/list.d.ts +2 -0
  53. package/dist/list.js +5 -0
  54. package/dist/media-DZ292aKK.js +557 -0
  55. package/dist/media-DZ292aKK.js.map +1 -0
  56. package/dist/media.d.ts +3 -0
  57. package/dist/media.js +3 -0
  58. package/dist/tree-Dd9Z0Aso.js +3351 -0
  59. package/dist/tree-Dd9Z0Aso.js.map +1 -0
  60. package/dist/tree.d.ts +2 -0
  61. package/dist/tree.js +6 -0
  62. package/dist/types-common-CB3kRek8.d.ts +26 -0
  63. package/dist/types-common-CB3kRek8.d.ts.map +1 -0
  64. package/dist/utils-B4fdKKsy.js +3 -0
  65. package/package.json +109 -0
  66. package/src/calendar/AgendaView.tsx +37 -0
  67. package/src/calendar/CalendarBrowser.tsx +90 -0
  68. package/src/calendar/CalendarModel.ts +142 -0
  69. package/src/calendar/CalendarSidebar.tsx +81 -0
  70. package/src/calendar/DayView.tsx +76 -0
  71. package/src/calendar/EventCard.tsx +51 -0
  72. package/src/calendar/MockCalendarProvider.ts +98 -0
  73. package/src/calendar/MonthView.tsx +77 -0
  74. package/src/calendar/WeekView.tsx +129 -0
  75. package/src/calendar/index.ts +18 -0
  76. package/src/calendar/types.ts +25 -0
  77. package/src/contacts/ContactAvatar.tsx +35 -0
  78. package/src/contacts/ContactBrowser.tsx +56 -0
  79. package/src/contacts/ContactCard.tsx +37 -0
  80. package/src/contacts/ContactDetail.tsx +63 -0
  81. package/src/contacts/ContactGroupSidebar.tsx +40 -0
  82. package/src/contacts/ContactList.tsx +32 -0
  83. package/src/contacts/ContactListModel.ts +120 -0
  84. package/src/contacts/MockContactProvider.ts +77 -0
  85. package/src/contacts/index.ts +17 -0
  86. package/src/contacts/types.ts +26 -0
  87. package/src/demos/CalendarBrowserDemo.tsx +15 -0
  88. package/src/demos/ContactBrowserDemo.tsx +15 -0
  89. package/src/demos/MediaBrowserDemo.tsx +15 -0
  90. package/src/file-browser/adapters/DocumentViewerAdapter.ts +371 -0
  91. package/src/file-browser/adapters/FileSystemBridge.ts +168 -0
  92. package/src/file-browser/adapters/GitBrowserAdapter.ts +546 -0
  93. package/src/file-browser/adapters/README.md +504 -0
  94. package/src/file-browser/adapters/index.ts +27 -0
  95. package/src/file-browser/adapters/types.ts +70 -0
  96. package/src/file-browser/architecture.md +645 -0
  97. package/src/file-browser/components/CreateItemDialog.tsx +71 -0
  98. package/src/file-browser/components/DeleteConfirmDialog.tsx +58 -0
  99. package/src/file-browser/components/FileBrowser.tsx +473 -0
  100. package/src/file-browser/components/FileBrowserContent.tsx +209 -0
  101. package/src/file-browser/components/FileBrowserHeader.tsx +151 -0
  102. package/src/file-browser/components/FileBrowserToolbar.tsx +145 -0
  103. package/src/file-browser/components/LeftPanel/LeftPanel.tsx +103 -0
  104. package/src/file-browser/components/LeftPanel/LeftPanelTabs.tsx +70 -0
  105. package/src/file-browser/components/LeftPanel/TreeNavigationView.tsx +256 -0
  106. package/src/file-browser/components/PreviewPane.tsx +146 -0
  107. package/src/file-browser/components/RightPanel/FilePreview.tsx +219 -0
  108. package/src/file-browser/components/RightPanel/RightPanel.tsx +186 -0
  109. package/src/file-browser/components/RightPanel/RightPanelToolbar.tsx +113 -0
  110. package/src/file-browser/components/UploadProgress.tsx +123 -0
  111. package/src/file-browser/components/ViewerHost.tsx +208 -0
  112. package/src/file-browser/components/mobile/MobileNavigation.tsx +227 -0
  113. package/src/file-browser/components/navigation/NavigationButtons.tsx +171 -0
  114. package/src/file-browser/components/shared/ErrorBoundary.tsx +116 -0
  115. package/src/file-browser/components/shared/FileBrowserItem.tsx +195 -0
  116. package/src/file-browser/components/shared/FileIcon.tsx +169 -0
  117. package/src/file-browser/components/toolbar/ViewModeToggle.tsx +200 -0
  118. package/src/file-browser/components/views/ListView/ListView.tsx +484 -0
  119. package/src/file-browser/components/views/ThumbnailView/ThumbnailView.tsx +323 -0
  120. package/src/file-browser/components/views/TreeView/TreeNode.tsx +186 -0
  121. package/src/file-browser/components/views/TreeView/TreeNodeList.tsx +191 -0
  122. package/src/file-browser/components/views/TreeView/TreeView.tsx +200 -0
  123. package/src/file-browser/components/views/TreemapView/TreemapView.tsx +339 -0
  124. package/src/file-browser/context/FileBrowserContext.tsx +13 -0
  125. package/src/file-browser/examples/BasicUsage.tsx +20 -0
  126. package/src/file-browser/index.ts +98 -0
  127. package/src/file-browser/models/FileBrowserModel.ts +623 -0
  128. package/src/file-browser/models/LeftPanelManagerModel.ts +105 -0
  129. package/src/file-browser/models/NavigationManagerModel.ts +312 -0
  130. package/src/file-browser/models/ResponsiveLayoutManagerModel.ts +437 -0
  131. package/src/file-browser/models/RightPanelManagerModel.ts +190 -0
  132. package/src/file-browser/models/SelectionManagerModel.ts +252 -0
  133. package/src/file-browser/models/ToolbarManagerModel.ts +144 -0
  134. package/src/file-browser/models/UploadModel.ts +147 -0
  135. package/src/file-browser/models/ViewModeManagerModel.ts +185 -0
  136. package/src/file-browser/models/ViewerHostModel.ts +44 -0
  137. package/src/file-browser/models/ui/ListViewUIModel.ts +265 -0
  138. package/src/file-browser/models/ui/PreviewUIModel.ts +297 -0
  139. package/src/file-browser/models/ui/ThumbnailViewUIModel.ts +254 -0
  140. package/src/file-browser/models/ui/TreeViewUIModel.ts +128 -0
  141. package/src/file-browser/models/ui/TreemapViewUIModel.ts +350 -0
  142. package/src/file-browser/providers/FileSystemListProvider.ts +552 -0
  143. package/src/file-browser/providers/FileSystemProvider.ts +401 -0
  144. package/src/file-browser/providers/FileSystemTreeProvider.ts +231 -0
  145. package/src/file-browser/providers/GitProvider.ts +337 -0
  146. package/src/file-browser/providers/GitRepositoryProvider.ts +376 -0
  147. package/src/file-browser/providers/IFileBrowserProvider.ts +56 -0
  148. package/src/file-browser/providers/MemoryProvider.ts +303 -0
  149. package/src/file-browser/providers/index.ts +4 -0
  150. package/src/file-browser/registry/ViewerRegistry.ts +551 -0
  151. package/src/file-browser/registry/types.ts +144 -0
  152. package/src/file-browser/scripts/performanceBenchmark.ts +553 -0
  153. package/src/file-browser/services/ThumbnailCacheService.ts +128 -0
  154. package/src/file-browser/tasks.md +537 -0
  155. package/src/file-browser/types/FileBrowserTypes.ts +126 -0
  156. package/src/file-browser/types/ProviderTypes.ts +155 -0
  157. package/src/file-browser/types/UITypes.ts +235 -0
  158. package/src/file-browser/types/ViewModeTypes.ts +150 -0
  159. package/src/file-browser/utils/gestures.ts +327 -0
  160. package/src/file-browser/utils/performance.ts +563 -0
  161. package/src/file-browser/viewers/ImageViewer.tsx +163 -0
  162. package/src/file-browser/viewers/ImageViewerModel.ts +79 -0
  163. package/src/file-browser/viewers/TextViewer.tsx +95 -0
  164. package/src/file-browser/viewers/UnsupportedFileViewer.tsx +57 -0
  165. package/src/file-browser/viewers/index.ts +61 -0
  166. package/src/git/BranchList.tsx +128 -0
  167. package/src/git/CommitGraph.tsx +239 -0
  168. package/src/git/CommitList.tsx +258 -0
  169. package/src/git/DiffViewer.tsx +219 -0
  170. package/src/git/index.ts +4 -0
  171. package/src/icons/iconMap.ts +146 -0
  172. package/src/icons/index.ts +9 -0
  173. package/src/index.ts +13 -0
  174. package/src/layout/README.md +307 -0
  175. package/src/layout/components/ExplorerLayout/ExplorerLayout.tsx +178 -0
  176. package/src/layout/examples/SimpleExample.tsx +60 -0
  177. package/src/layout/index.ts +6 -0
  178. package/src/lib/utils.ts +1 -0
  179. package/src/list/README.md +303 -0
  180. package/src/list/architecture.md +807 -0
  181. package/src/list/components/CalculatedGridView.tsx +252 -0
  182. package/src/list/components/DragPreview.tsx +102 -0
  183. package/src/list/components/ListContextMenu.tsx +274 -0
  184. package/src/list/components/ListItem.tsx +761 -0
  185. package/src/list/components/ListItems.tsx +919 -0
  186. package/src/list/components/MasonryView.tsx +241 -0
  187. package/src/list/components/SearchFilter.tsx +44 -0
  188. package/src/list/components/TreemapView.tsx +709 -0
  189. package/src/list/components/ViewSizeControls.tsx +205 -0
  190. package/src/list/components/ViewTypeSelector.tsx +312 -0
  191. package/src/list/components/VirtualizedDetailsView.tsx +231 -0
  192. package/src/list/components/VirtualizedGrid.tsx +164 -0
  193. package/src/list/components/VirtualizedList.tsx +154 -0
  194. package/src/list/components/VirtualizedMasonryView.tsx +344 -0
  195. package/src/list/components/shared/EmptyState.tsx +103 -0
  196. package/src/list/components/shared/ErrorBoundary.tsx +123 -0
  197. package/src/list/components/shared/ErrorDisplay.tsx +100 -0
  198. package/src/list/components/shared/ListLoader.tsx +146 -0
  199. package/src/list/components/shared/LoadingIndicator.tsx +80 -0
  200. package/src/list/index.ts +92 -0
  201. package/src/list/models/ListItemsModel.ts +1301 -0
  202. package/src/list/models/TreemapModel.ts +204 -0
  203. package/src/list/providers/ListItemsProvider.ts +313 -0
  204. package/src/list/providers/TestListProvider.ts +604 -0
  205. package/src/list/tasks.md +937 -0
  206. package/src/list/types/ListTypes.ts +178 -0
  207. package/src/list/utils/BenchmarkLogger.ts +243 -0
  208. package/src/list/utils/DragDropManager.ts +320 -0
  209. package/src/list/utils/GridLayoutCalculator.ts +290 -0
  210. package/src/list/utils/ListAccessibility.ts +367 -0
  211. package/src/list/utils/ListKeyboard.ts +414 -0
  212. package/src/list/utils/MasonryLayoutCalculator.ts +302 -0
  213. package/src/list/utils/MasonryLayoutEngine.ts +401 -0
  214. package/src/list/utils/__tests__/MasonryLayoutEngine.test.ts +157 -0
  215. package/src/list/utils/__tests__/VirtualizedMasonryView.test.tsx +251 -0
  216. package/src/media/AlbumSidebar.tsx +48 -0
  217. package/src/media/MediaBrowser.tsx +92 -0
  218. package/src/media/MediaBrowserModel.ts +138 -0
  219. package/src/media/MediaGrid.tsx +50 -0
  220. package/src/media/MediaList.tsx +49 -0
  221. package/src/media/MediaPreview.tsx +63 -0
  222. package/src/media/MediaTimeline.tsx +38 -0
  223. package/src/media/MockMediaProvider.ts +70 -0
  224. package/src/media/index.ts +18 -0
  225. package/src/media/types.ts +21 -0
  226. package/src/styles/variables.css +60 -0
  227. package/src/tree/DEVELOPMENT_SUMMARY.md +170 -0
  228. package/src/tree/__tests__/TreeModel.test.ts +16 -0
  229. package/src/tree/architecture.md +530 -0
  230. package/src/tree/components/Tree.tsx +283 -0
  231. package/src/tree/components/TreeCheckbox.tsx +147 -0
  232. package/src/tree/components/TreeContextMenu.tsx +139 -0
  233. package/src/tree/components/TreeNodeList.tsx +329 -0
  234. package/src/tree/components/TreeTable.tsx +382 -0
  235. package/src/tree/index.ts +58 -0
  236. package/src/tree/models/TreeModel.ts +839 -0
  237. package/src/tree/providers/SimpleTreeProvider.ts +463 -0
  238. package/src/tree/providers/TestTreeProvider.ts +946 -0
  239. package/src/tree/providers/TreeProvider.ts +308 -0
  240. package/src/tree/tasks.md +2046 -0
  241. package/src/tree/types/TreeTypes.ts +279 -0
  242. package/src/tree/utils/SelectionTheme.ts +150 -0
  243. package/src/tree/utils/logger.ts +203 -0
  244. package/src/types-common.ts +21 -0
@@ -0,0 +1,1301 @@
1
+ import { makeAutoObservable, observable, flow } from "mobx";
2
+ import {
3
+ LIST_VIEW_TYPE,
4
+ } from "../providers/ListItemsProvider";
5
+ import type {
6
+ ListItemsProvider,
7
+ ListItemsProviderListener,
8
+ ListViewType,
9
+ } from "../providers/ListItemsProvider";
10
+ import type {
11
+ ListItemData,
12
+ ListLoadOptions,
13
+ ListLoadResult,
14
+ ListSelectionInfo,
15
+ ListDragDropInfo
16
+ } from "../types/ListTypes";
17
+ import { benchmark } from "../utils/BenchmarkLogger";
18
+ import { GridLayoutCalculator, createGridCalculator, type GridItemLayout } from "../utils/GridLayoutCalculator";
19
+ import { MasonryLayoutEngine, createVerticalMasonry, createHorizontalMasonry, type MasonryItem, type MasonryItemPosition } from "../utils/MasonryLayoutEngine";
20
+
21
+ // AICODE-NOTE: List could be in different states: thumbnail, grid, list, details and shuld be possible to define custom states..
22
+ // AICODE-NOTE: It could be used in file explorer like windows explorer or finder but also in other places
23
+ // AICODE-NOTE: Should use virtualized list like react-window
24
+ // AICODE-NOTE: for provider/model design see ../TreeComponent/models/TreeModel.ts
25
+
26
+ // AICODE-NOTE: Skeleton UI or shimmer for items while loading; graceful error display.
27
+ // AICODE-NOTE: Expose imperative methods (scroll to item, focus item, programmatically select, refresh, etc.).
28
+ // AICODE-NOTE: Support single, multiple, and range selection with shift/control modifiers; expose selected item(s) via API.
29
+ // AICODE-NOTE: Think about Item interface, it should be flexible and support different types of items.
30
+
31
+ export class ListItemsModel implements ListItemsProviderListener {
32
+ currentViewType: ListViewType = LIST_VIEW_TYPE;
33
+
34
+ // AICODE-NOTE: Core data using observable collections for reactive updates
35
+ _allItems: ListItemData[] = [];
36
+ itemMap = observable.map<string, ListItemData>();
37
+ totalItemCount: number = 0;
38
+
39
+ // Search/filter state
40
+ searchQuery: string = '';
41
+
42
+ // AICODE-NOTE: Selection state using observable.map for O(1) lookups
43
+ selectedItems = observable.map<string, ListItemData>();
44
+ previousSelection: ListItemData[] = [];
45
+ focusedItem: string | null = null;
46
+
47
+ // Range selection anchor - tracks the starting point for shift+click range selection
48
+ selectionAnchor: string | null = null;
49
+
50
+ // AICODE-NOTE: Loading state for different ranges and operations
51
+ isLoading: boolean = false;
52
+ loadingRanges = observable.map<string, boolean>();
53
+ errors = observable.map<string, Error>();
54
+
55
+ // AICODE-NOTE: Virtualization state for handling huge datasets
56
+ viewportRange: { start: number; end: number } = { start: 0, end: 0 };
57
+ scrollPosition: number = 0;
58
+ containerSize: { width: number; height: number } = { width: 0, height: 0 };
59
+
60
+ // AICODE-NOTE: Drag and drop state
61
+ isDragging: boolean = false;
62
+ draggedItems: ListItemData[] = [];
63
+ dragOverItem: string | null = null;
64
+ dragOverPosition: 'before' | 'after' | 'inside' | null = null;
65
+
66
+ // AICODE-NOTE: View size management state
67
+ itemSize: 'small' | 'medium' | 'large' | 'extra-large' = 'medium';
68
+ customItemWidth: number = 200;
69
+ customItemHeight: number = 220;
70
+ itemsPerRow: number | 'auto' = 'auto';
71
+
72
+ // AICODE-NOTE: Layout calculators for precise positioning
73
+ private gridCalculator: GridLayoutCalculator | null = null;
74
+ private verticalMasonryEngine: MasonryLayoutEngine | null = null;
75
+ private horizontalMasonryEngine: MasonryLayoutEngine | null = null;
76
+
77
+ // AICODE-NOTE: Debug visualization toggle
78
+ debugVisualization: boolean = false;
79
+
80
+ // Compact mode: details view shows only Icon + Name
81
+ compactMode: boolean = false;
82
+
83
+ // Column visibility for details view (only relevant when compactMode is false)
84
+ columnVisibility: { type: boolean; modified: boolean; size: boolean } = {
85
+ type: true,
86
+ modified: true,
87
+ size: true
88
+ };
89
+
90
+ // Checkbox mode for multi-select
91
+ showCheckboxes: boolean = false;
92
+
93
+ // Scroll-to-item: views watch this and scroll their container when set
94
+ scrollToItemId: string | null = null;
95
+
96
+ // Pagination state
97
+ pageSize: number = 50;
98
+ currentPage: number = 0;
99
+
100
+ constructor(public provider: ListItemsProvider) {
101
+ makeAutoObservable(this, {});
102
+ }
103
+
104
+ // =====================
105
+ // Computed filtered items
106
+ // =====================
107
+
108
+ get items(): ListItemData[] {
109
+ if (!this.searchQuery) return this._allItems;
110
+ const q = this.searchQuery.toLowerCase();
111
+ return this._allItems.filter(item => item.name.toLowerCase().includes(q));
112
+ }
113
+
114
+ get hasSearchQuery(): boolean {
115
+ return this.searchQuery.length > 0;
116
+ }
117
+
118
+ get allItemsCount(): number {
119
+ return this._allItems.length;
120
+ }
121
+
122
+ setSearchQuery(query: string): void {
123
+ this.searchQuery = query;
124
+ }
125
+
126
+ clearSearch(): void {
127
+ this.searchQuery = '';
128
+ }
129
+
130
+ clearItems(): void {
131
+ this._allItems = [];
132
+ }
133
+
134
+ // =====================
135
+ // ListItemsProviderListener Implementation
136
+ // =====================
137
+
138
+ onItemsCountChanged(count: number): void {
139
+ this.totalItemCount = count;
140
+ // AICODE-NOTE: Notify UI components that the total count has changed
141
+ // This is useful for virtualization and pagination
142
+ }
143
+
144
+ onItemChanged(itemIndex: number): void {
145
+ // AICODE-NOTE: Handle individual item changes for reactive updates
146
+ // This allows providers to notify of granular changes without full reload
147
+ if (itemIndex >= 0 && itemIndex < this._allItems.length) {
148
+ // Trigger re-render for specific item by updating the map
149
+ const item = this._allItems[itemIndex];
150
+ if (item) {
151
+ this.itemMap.set(item.id, { ...item });
152
+ }
153
+ }
154
+ }
155
+
156
+ // =====================
157
+ // Computed Properties
158
+ // =====================
159
+
160
+ get selectedItemsArray(): ListItemData[] {
161
+ return Array.from(this.selectedItems.values());
162
+ }
163
+
164
+ get hasSelection(): boolean {
165
+ return this.selectedItems.size > 0;
166
+ }
167
+
168
+ get isItemSelected() {
169
+ return (itemId: string) => this.selectedItems.has(itemId);
170
+ }
171
+
172
+ get itemHeight(): number {
173
+ return this.provider.getItemHeight(this.currentViewType);
174
+ }
175
+
176
+ get isLoaded(): boolean {
177
+ return !this.isLoading && this._allItems.length > 0;
178
+ }
179
+
180
+ get hasErrors(): boolean {
181
+ return this.errors.size > 0;
182
+ }
183
+
184
+ // AICODE-NOTE: Performance optimized computed properties
185
+ get selectedItemIds(): Set<string> {
186
+ return new Set(this.selectedItems.keys());
187
+ }
188
+
189
+ get visibleItemsCount(): number {
190
+ const { start, end } = this.viewportRange;
191
+ return Math.max(0, end - start + 1);
192
+ }
193
+
194
+ get loadedItemsCount(): number {
195
+ return this._allItems.filter(item => item !== null && item !== undefined).length;
196
+ }
197
+
198
+ get loadingProgress(): number {
199
+ if (this.totalItemCount === 0) return 0;
200
+ return Math.min(100, (this.loadedItemsCount / this.totalItemCount) * 100);
201
+ }
202
+
203
+ get isFullyLoaded(): boolean {
204
+ return this.loadedItemsCount >= this.totalItemCount;
205
+ }
206
+
207
+ get totalPages(): number {
208
+ if (this.pageSize <= 0 || this.totalItemCount <= 0) return 0;
209
+ return Math.ceil(this.totalItemCount / this.pageSize);
210
+ }
211
+
212
+ get hasNextPage(): boolean {
213
+ return this.currentPage < this.totalPages - 1;
214
+ }
215
+
216
+ get hasPreviousPage(): boolean {
217
+ return this.currentPage > 0;
218
+ }
219
+
220
+ // =====================
221
+ // Pagination Actions
222
+ // =====================
223
+
224
+ goToPage = flow(function* (this: ListItemsModel, page: number) {
225
+ if (page < 0 || page >= this.totalPages) return;
226
+ this.currentPage = page;
227
+ yield this.loadItems({ limit: this.pageSize, offset: page * this.pageSize });
228
+ });
229
+
230
+ nextPage = flow(function* (this: ListItemsModel) {
231
+ if (this.hasNextPage) {
232
+ yield this.goToPage(this.currentPage + 1);
233
+ }
234
+ });
235
+
236
+ prevPage = flow(function* (this: ListItemsModel) {
237
+ if (this.hasPreviousPage) {
238
+ yield this.goToPage(this.currentPage - 1);
239
+ }
240
+ });
241
+
242
+ setPageSize(size: number): void {
243
+ if (size > 0) {
244
+ this.pageSize = size;
245
+ this.currentPage = 0;
246
+ }
247
+ }
248
+
249
+ // =====================
250
+ // Async Actions (using flow for MobX best practices)
251
+ // =====================
252
+
253
+ /**
254
+ * Load items from the provider
255
+ * Uses MobX flow for proper async action handling
256
+ */
257
+ loadItems = flow(function* (this: ListItemsModel, options?: ListLoadOptions) {
258
+ benchmark.start('loadItems', {
259
+ itemCount: this.items.length,
260
+ viewType: this.currentViewType.id,
261
+ options
262
+ });
263
+
264
+ this.isLoading = true;
265
+ this.errors.clear();
266
+
267
+ try {
268
+ benchmark.start('provider.loadItems');
269
+ const result: ListLoadResult = yield this.provider.loadItems(options);
270
+ benchmark.end('provider.loadItems', { itemCount: result.items.length });
271
+
272
+ benchmark.start('updateItemsAndMap');
273
+ // Update items and item map
274
+ this._allItems = result.items;
275
+ this.totalItemCount = result.totalCount ?? result.items.length;
276
+
277
+ // Update item map for fast lookups
278
+ this.itemMap.clear();
279
+ result.items.forEach((item: ListItemData) => this.itemMap.set(item.id, item));
280
+ benchmark.end('updateItemsAndMap', { itemCount: result.items.length });
281
+
282
+ } catch (error) {
283
+ const errorKey = 'loadItems';
284
+ this.errors.set(errorKey, error instanceof Error ? error : new Error(String(error)));
285
+ } finally {
286
+ this.isLoading = false;
287
+ benchmark.end('loadItems', {
288
+ finalItemCount: this.items.length,
289
+ hasErrors: this.errors.size > 0
290
+ });
291
+ }
292
+ });
293
+
294
+ /**
295
+ * Load a specific range of items for virtualization
296
+ */
297
+ loadItemRange = flow(function* (this: ListItemsModel, start: number, end: number) {
298
+ const rangeKey = `${start}-${end}`;
299
+ this.loadingRanges.set(rangeKey, true);
300
+
301
+ try {
302
+ const result: ListLoadResult = yield this.provider.loadItemRange?.(start, end) ?? { items: [] };
303
+
304
+ // Merge items into existing array at correct positions
305
+ result.items.forEach((item, index) => {
306
+ const absoluteIndex = start + index;
307
+ this._allItems[absoluteIndex] = item;
308
+ this.itemMap.set(item.id, item);
309
+ });
310
+
311
+ } catch (error) {
312
+ this.errors.set(rangeKey, error instanceof Error ? error : new Error(String(error)));
313
+ } finally {
314
+ this.loadingRanges.delete(rangeKey);
315
+ }
316
+ });
317
+
318
+ /**
319
+ * Refresh items from provider
320
+ */
321
+ refresh = flow(function* (this: ListItemsModel) {
322
+ if (this.provider.refresh) {
323
+ this.isLoading = true;
324
+ this.errors.clear();
325
+
326
+ try {
327
+ const result: ListLoadResult = yield this.provider.refresh();
328
+ this._allItems = result.items;
329
+ this.totalItemCount = result.totalCount ?? result.items.length;
330
+
331
+ // Update item map
332
+ this.itemMap.clear();
333
+ result.items.forEach((item: ListItemData) => this.itemMap.set(item.id, item));
334
+
335
+ } catch (error) {
336
+ this.errors.set('refresh', error instanceof Error ? error : new Error(String(error)));
337
+ } finally {
338
+ this.isLoading = false;
339
+ }
340
+ }
341
+ });
342
+
343
+ // =====================
344
+ // Selection Actions
345
+ // =====================
346
+
347
+ /**
348
+ * Select a single item with proper single/multi-selection logic
349
+ */
350
+ selectItem(item: ListItemData, modifiers?: { ctrl?: boolean; shift?: boolean }): void {
351
+ // Store previous selection for callback
352
+ this.previousSelection = [...this.selectedItemsArray];
353
+
354
+ const wasSelected = this.selectedItems.has(item.id);
355
+ const isMultiSelectMode = this.provider.isMultiSelectEnabled && modifiers?.ctrl;
356
+ const isRangeSelectMode = this.provider.isMultiSelectEnabled && modifiers?.shift;
357
+
358
+ if (isRangeSelectMode && this.selectionAnchor) {
359
+ // Shift+click: select range from anchor to clicked item
360
+ this.selectRange(this.selectionAnchor, item.id);
361
+ } else if (isMultiSelectMode) {
362
+ // Ctrl+click: toggle individual item
363
+ if (wasSelected) {
364
+ this.selectedItems.delete(item.id);
365
+ } else {
366
+ this.selectedItems.set(item.id, item);
367
+ }
368
+ // Update anchor to current item for future range selections
369
+ this.selectionAnchor = item.id;
370
+ } else {
371
+ // Plain click: single selection
372
+ this.selectedItems.clear();
373
+ this.selectedItems.set(item.id, item);
374
+ // Set anchor for future shift+click range selections
375
+ this.selectionAnchor = item.id;
376
+ }
377
+
378
+ // Always update focused item when clicking
379
+ this.focusedItem = item.id;
380
+
381
+ // Notify provider of selection change
382
+ if (this.provider.onSelectionChange) {
383
+ this.provider.onSelectionChange({
384
+ selectedItems: this.selectedItemsArray,
385
+ previousSelection: this.previousSelection,
386
+ selectionType: isRangeSelectMode ? 'range' : (isMultiSelectMode ? 'multi' : 'single'),
387
+ trigger: 'click'
388
+ });
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Select a contiguous range of items between two item IDs
394
+ */
395
+ selectRange(fromId: string, toId: string): void {
396
+ const fromIndex = this.items.findIndex(item => item.id === fromId);
397
+ const toIndex = this.items.findIndex(item => item.id === toId);
398
+
399
+ if (fromIndex === -1 || toIndex === -1) return;
400
+
401
+ const start = Math.min(fromIndex, toIndex);
402
+ const end = Math.max(fromIndex, toIndex);
403
+
404
+ this.selectedItems.clear();
405
+ for (let i = start; i <= end; i++) {
406
+ const item = this.items[i];
407
+ if (item) {
408
+ this.selectedItems.set(item.id, item);
409
+ }
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Clear all selection
415
+ */
416
+ clearSelection(): void {
417
+ const hadSelection = this.selectedItems.size > 0;
418
+ if (!hadSelection) return;
419
+
420
+ // Store previous selection for callback
421
+ this.previousSelection = [...this.selectedItemsArray];
422
+ this.selectedItems.clear();
423
+
424
+ if (this.provider.onSelectionChange) {
425
+ this.provider.onSelectionChange({
426
+ selectedItems: [],
427
+ previousSelection: this.previousSelection,
428
+ selectionType: this.provider.isMultiSelectEnabled ? 'multi' : 'single',
429
+ trigger: 'api'
430
+ });
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Select all items (if multi-select enabled)
436
+ */
437
+ selectAll(): void {
438
+ if (!this.provider.isMultiSelectEnabled) return;
439
+
440
+ this.selectedItems.clear();
441
+ this.items.forEach(item => {
442
+ this.selectedItems.set(item.id, item);
443
+ });
444
+
445
+ if (this.provider.onSelectionChange) {
446
+ this.provider.onSelectionChange({
447
+ selectedItems: this.selectedItemsArray,
448
+ previousSelection: [],
449
+ selectionType: 'multi',
450
+ trigger: 'api'
451
+ });
452
+ }
453
+ }
454
+
455
+ // =====================
456
+ // View Type Management
457
+ // =====================
458
+
459
+ /**
460
+ * Set the current view type
461
+ */
462
+ setViewType(viewType: ListViewType): void {
463
+ console.log('ListItemsModel.setViewType called:', {
464
+ from: this.currentViewType.id,
465
+ to: viewType.id,
466
+ same: this.currentViewType.id === viewType.id
467
+ });
468
+
469
+ if (this.currentViewType.id !== viewType.id) {
470
+ this.currentViewType = viewType;
471
+ console.log('View type changed to:', this.currentViewType.id);
472
+
473
+ // Notify provider
474
+ if (this.provider.onViewTypeChange) {
475
+ this.provider.onViewTypeChange(viewType);
476
+ }
477
+ } else {
478
+ console.log('View type not changed - same as current');
479
+ }
480
+ }
481
+
482
+ // =====================
483
+ // Size Management
484
+ // =====================
485
+
486
+ /**
487
+ * Set item size for grid and masonry views
488
+ */
489
+ setItemSize(size: 'small' | 'medium' | 'large' | 'extra-large'): void {
490
+ this.itemSize = size;
491
+
492
+ // Update custom dimensions based on size
493
+ const sizeMap = {
494
+ 'small': { width: 160, height: 120 },
495
+ 'medium': { width: 240, height: 180 },
496
+ 'large': { width: 320, height: 240 },
497
+ 'extra-large': { width: 400, height: 300 }
498
+ };
499
+
500
+ const dimensions = sizeMap[size];
501
+ this.customItemWidth = dimensions.width;
502
+ this.customItemHeight = dimensions.height;
503
+
504
+ // Update calculators with new dimensions
505
+ this.updateLayoutCalculators();
506
+ }
507
+
508
+ /**
509
+ * Set custom item width and update calculators
510
+ */
511
+ setCustomItemWidth(width: number): void {
512
+ this.customItemWidth = Math.max(120, Math.min(600, width));
513
+ this.updateLayoutCalculators();
514
+ }
515
+
516
+ /**
517
+ * Set custom item height and update calculators
518
+ */
519
+ setCustomItemHeight(height: number): void {
520
+ this.customItemHeight = Math.max(80, Math.min(500, height));
521
+ this.updateLayoutCalculators();
522
+ }
523
+
524
+ /**
525
+ * Get current item dimensions
526
+ */
527
+ get itemDimensions(): { width: number; height: number } {
528
+ return {
529
+ width: this.customItemWidth,
530
+ height: this.customItemHeight
531
+ };
532
+ }
533
+
534
+ /**
535
+ * Set items per row for grid and masonry views
536
+ */
537
+ setItemsPerRow(itemsPerRow: number | 'auto'): void {
538
+ this.itemsPerRow = itemsPerRow;
539
+ this.updateLayoutCalculators();
540
+ }
541
+
542
+ /**
543
+ * Set debug visualization mode
544
+ */
545
+ setDebugVisualization(enabled: boolean): void {
546
+ this.debugVisualization = enabled;
547
+ }
548
+
549
+ setCompactMode(compact: boolean): void {
550
+ this.compactMode = compact;
551
+ }
552
+
553
+ toggleCheckboxes(): void {
554
+ this.showCheckboxes = !this.showCheckboxes;
555
+ }
556
+
557
+ setShowCheckboxes(show: boolean): void {
558
+ this.showCheckboxes = show;
559
+ }
560
+
561
+ setColumnVisibility(column: 'type' | 'modified' | 'size', visible: boolean): void {
562
+ this.columnVisibility = { ...this.columnVisibility, [column]: visible };
563
+ }
564
+
565
+ get detailsGridTemplateColumns(): string {
566
+ const cols: string[] = [];
567
+ if (this.showCheckboxes) cols.push('20px');
568
+ cols.push('24px', 'minmax(0, 1fr)');
569
+ if (!this.compactMode) {
570
+ if (this.columnVisibility.type) cols.push('100px');
571
+ if (this.columnVisibility.modified) cols.push('140px');
572
+ if (this.columnVisibility.size) cols.push('90px');
573
+ }
574
+ return cols.join(' ');
575
+ }
576
+
577
+ /**
578
+ * Generate random aspect ratio for Pinterest-like variety
579
+ */
580
+ /**
581
+ * Deterministic aspect ratio based on item id (stable across re-renders).
582
+ * Uses a simple hash to pick from predefined ratios.
583
+ */
584
+ private generateDeterministicAspectRatio(id: string): number {
585
+ const ratios = [0.6, 0.7, 0.8, 1.0, 1.2, 1.4, 1.6];
586
+ let hash = 0;
587
+ for (let i = 0; i < id.length; i++) {
588
+ hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
589
+ }
590
+ const index = Math.abs(hash) % ratios.length;
591
+ return ratios[index] ?? 1.0;
592
+ }
593
+
594
+ /**
595
+ * Get effective items per row based on view and container width
596
+ */
597
+ getEffectiveItemsPerRow(containerWidth?: number): number {
598
+ if (typeof this.itemsPerRow === 'number') {
599
+ return this.itemsPerRow;
600
+ }
601
+
602
+ // Auto calculation based on container width and item width
603
+ if (containerWidth) {
604
+ const itemWidth = this.customItemWidth;
605
+ const gap = 12; // Default gap
606
+ const availableWidth = containerWidth - (gap * 2);
607
+ const itemsWithGaps = Math.floor((availableWidth + gap) / (itemWidth + gap));
608
+ return Math.max(1, Math.min(8, itemsWithGaps));
609
+ }
610
+
611
+ return 4; // Default fallback
612
+ }
613
+
614
+ // =====================
615
+ // Focus Management
616
+ // =====================
617
+
618
+ /**
619
+ * Set focused item
620
+ */
621
+ setFocusedItem(itemId: string | null): void {
622
+ this.focusedItem = itemId;
623
+ }
624
+
625
+ /**
626
+ * Focus next item
627
+ */
628
+ focusNext(): boolean {
629
+ if (this.items.length === 0) return false;
630
+
631
+ const currentIndex = this.focusedItem
632
+ ? this.items.findIndex(item => item.id === this.focusedItem)
633
+ : -1;
634
+
635
+ const nextIndex = currentIndex < this.items.length - 1 ? currentIndex + 1 : 0;
636
+ const nextItem = this.items[nextIndex];
637
+ if (nextItem) {
638
+ this.focusedItem = nextItem.id;
639
+ }
640
+ return true;
641
+ }
642
+
643
+ /**
644
+ * Focus previous item
645
+ */
646
+ focusPrevious(): boolean {
647
+ if (this.items.length === 0) return false;
648
+
649
+ const currentIndex = this.focusedItem
650
+ ? this.items.findIndex(item => item.id === this.focusedItem)
651
+ : 0;
652
+
653
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : this.items.length - 1;
654
+ const prevItem = this.items[prevIndex];
655
+ if (prevItem) {
656
+ this.focusedItem = prevItem.id;
657
+ }
658
+ return true;
659
+ }
660
+
661
+ /**
662
+ * Focus the first item in the list
663
+ */
664
+ focusFirst(): boolean {
665
+ if (this.items.length === 0) return false;
666
+ const firstItem = this.items[0];
667
+ if (firstItem) {
668
+ this.focusedItem = firstItem.id;
669
+ this.ensureItemVisible(firstItem.id);
670
+ }
671
+ return true;
672
+ }
673
+
674
+ /**
675
+ * Focus the last item in the list
676
+ */
677
+ focusLast(): boolean {
678
+ if (this.items.length === 0) return false;
679
+ const lastItem = this.items[this.items.length - 1];
680
+ if (lastItem) {
681
+ this.focusedItem = lastItem.id;
682
+ this.ensureItemVisible(lastItem.id);
683
+ }
684
+ return true;
685
+ }
686
+
687
+ // =====================
688
+ // Keyboard Navigation
689
+ // =====================
690
+
691
+ /**
692
+ * Handle keyboard events for list navigation.
693
+ * Can be used directly via onKeyDown on a container element,
694
+ * or the useListKeyboard hook can be used for automatic attachment.
695
+ *
696
+ * - ArrowUp/ArrowDown: navigate between items
697
+ * - Home/End: jump to first/last item
698
+ * - Enter: open focused item (navigate into folder or open file)
699
+ * - Space: toggle selection of focused item
700
+ * - Escape: clear selection, or navigate up one directory level if nothing selected
701
+ * - Ctrl+A: select all items
702
+ */
703
+ handleKeyDown(event: KeyboardEvent): void {
704
+ // Skip when an input/textarea is focused (e.g. inline rename)
705
+ const tag = (event.target as HTMLElement)?.tagName;
706
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
707
+
708
+ if (this.items.length === 0) return;
709
+
710
+ const { key, ctrlKey, metaKey, shiftKey } = event;
711
+ const isModifier = ctrlKey || metaKey;
712
+
713
+ switch (key) {
714
+ case 'ArrowUp': {
715
+ event.preventDefault();
716
+ const idx = this.getFocusedIndex();
717
+ if (idx > 0) {
718
+ this.navigateToIndex(idx - 1, shiftKey);
719
+ }
720
+ break;
721
+ }
722
+ case 'ArrowDown': {
723
+ event.preventDefault();
724
+ const idx = this.getFocusedIndex();
725
+ if (idx < this.items.length - 1) {
726
+ this.navigateToIndex(idx + 1, shiftKey);
727
+ }
728
+ break;
729
+ }
730
+ case 'Home': {
731
+ event.preventDefault();
732
+ this.navigateToIndex(0, shiftKey);
733
+ break;
734
+ }
735
+ case 'End': {
736
+ event.preventDefault();
737
+ this.navigateToIndex(this.items.length - 1, shiftKey);
738
+ break;
739
+ }
740
+ case 'Enter': {
741
+ event.preventDefault();
742
+ const item = this.focusedItem ? this.getItem(this.focusedItem) : this.items[0];
743
+ if (item && this.provider.onItemDoubleClick) {
744
+ this.provider.onItemDoubleClick(item);
745
+ }
746
+ break;
747
+ }
748
+ case ' ': {
749
+ event.preventDefault();
750
+ const item = this.focusedItem ? this.getItem(this.focusedItem) : this.items[0];
751
+ if (item) {
752
+ this.selectItem(item, { ctrl: true, shift: shiftKey });
753
+ }
754
+ break;
755
+ }
756
+ case 'Escape': {
757
+ event.preventDefault();
758
+ if (this.hasSelection) {
759
+ this.clearSelection();
760
+ } else if (this.provider.onNavigateUp) {
761
+ this.provider.onNavigateUp();
762
+ }
763
+ break;
764
+ }
765
+ case 'a':
766
+ case 'A': {
767
+ if (isModifier) {
768
+ event.preventDefault();
769
+ this.selectAll();
770
+ }
771
+ break;
772
+ }
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Get the index of the currently focused item, defaulting to 0
778
+ */
779
+ private getFocusedIndex(): number {
780
+ if (!this.focusedItem) return 0;
781
+ const idx = this.items.findIndex(item => item.id === this.focusedItem);
782
+ return idx === -1 ? 0 : idx;
783
+ }
784
+
785
+ /**
786
+ * Navigate to a specific item index, updating focus, selection, and scroll
787
+ */
788
+ private navigateToIndex(index: number, extendSelection: boolean = false): void {
789
+ const item = this.items[index];
790
+ if (!item) return;
791
+
792
+ this.focusedItem = item.id;
793
+
794
+ if (extendSelection && this.provider.isMultiSelectEnabled) {
795
+ if (!this.selectionAnchor) {
796
+ this.selectionAnchor = this.focusedItem || item.id;
797
+ }
798
+ this.selectRange(this.selectionAnchor, item.id);
799
+ } else {
800
+ this.selectItem(item);
801
+ }
802
+
803
+ this.ensureItemVisible(item.id);
804
+ }
805
+
806
+ // =====================
807
+ // Drag and Drop Management
808
+ // =====================
809
+
810
+ /**
811
+ * Check if an item can be dragged
812
+ */
813
+ canDragItem(item: ListItemData): boolean {
814
+ if (!this.provider.isDragDropEnabled) return false;
815
+ return this.provider.canDragItem?.(item) ?? true;
816
+ }
817
+
818
+ /**
819
+ * Check if items can be dropped on a target
820
+ */
821
+ canDropItems(draggedItems: ListItemData[], targetItem: ListItemData | null, position: 'before' | 'after' | 'inside'): boolean {
822
+ if (!this.provider.isDragDropEnabled) return false;
823
+ return this.provider.canDropItems?.(draggedItems, targetItem, position) ?? true;
824
+ }
825
+
826
+ /**
827
+ * Start drag operation
828
+ */
829
+ startDrag(items: ListItemData[], event: DragEvent): void {
830
+ if (!this.provider.isDragDropEnabled) return;
831
+
832
+ this.isDragging = true;
833
+ this.draggedItems = items;
834
+
835
+ // AICODE-NOTE: Set drag data for HTML5 drag and drop
836
+ const itemIds = items.map(item => item.id);
837
+ event.dataTransfer?.setData('application/json', JSON.stringify({
838
+ type: 'list-items',
839
+ itemIds
840
+ }));
841
+
842
+ // AICODE-NOTE: Set drag effect
843
+ if (event.dataTransfer) {
844
+ event.dataTransfer.effectAllowed = 'move';
845
+ }
846
+
847
+ // Notify provider
848
+ if (this.provider.onDragStart) {
849
+ this.provider.onDragStart(items, event);
850
+ }
851
+ }
852
+
853
+ /**
854
+ * Handle drag over item
855
+ */
856
+ setDragOver(itemId: string | null, position: 'before' | 'after' | 'inside' | null): void {
857
+ this.dragOverItem = itemId;
858
+ this.dragOverPosition = position;
859
+ }
860
+
861
+ /**
862
+ * Handle drop operation
863
+ */
864
+ handleDrop(targetItem: ListItemData | null, position: 'before' | 'after' | 'inside', event: DragEvent): boolean {
865
+ if (!this.isDragging || this.draggedItems.length === 0) return false;
866
+
867
+ // AICODE-NOTE: Check if drop is allowed
868
+ if (!this.canDropItems(this.draggedItems, targetItem, position)) {
869
+ this.endDrag(false);
870
+ return false;
871
+ }
872
+
873
+ // AICODE-NOTE: Create drag drop info
874
+ const dragDropInfo: ListDragDropInfo = {
875
+ draggedItems: this.draggedItems,
876
+ targetItem,
877
+ position,
878
+ event
879
+ };
880
+
881
+ // Notify provider
882
+ if (this.provider.onDrop) {
883
+ this.provider.onDrop(dragDropInfo);
884
+ }
885
+
886
+ this.endDrag(true);
887
+ return true;
888
+ }
889
+
890
+ /**
891
+ * End drag operation
892
+ */
893
+ endDrag(success: boolean): void {
894
+ const draggedItems = [...this.draggedItems];
895
+
896
+ this.isDragging = false;
897
+ this.draggedItems = [];
898
+ this.dragOverItem = null;
899
+ this.dragOverPosition = null;
900
+
901
+ // Notify provider
902
+ if (this.provider.onDragEnd) {
903
+ this.provider.onDragEnd(draggedItems, success);
904
+ }
905
+ }
906
+
907
+ // =====================
908
+ // Virtualization Support
909
+ // =====================
910
+
911
+ /**
912
+ * Update viewport range for virtualization and trigger dynamic loading
913
+ */
914
+ updateViewportRange(start: number, end: number): void {
915
+ this.viewportRange = { start, end };
916
+
917
+ // AICODE-NOTE: Trigger dynamic loading for visible range
918
+ this.loadVisibleItems(start, end);
919
+ }
920
+
921
+ /**
922
+ * Load items that are visible in the current viewport
923
+ */
924
+ private loadVisibleItems = flow(function* (this: ListItemsModel, start: number, end: number) {
925
+ // AICODE-NOTE: Check if provider supports range loading
926
+ if (!this.provider.loadItemRange) {
927
+ return;
928
+ }
929
+
930
+ // AICODE-NOTE: Expand range to include overscan for smoother scrolling
931
+ const overscan = 10;
932
+ const expandedStart = Math.max(0, start - overscan);
933
+ const expandedEnd = Math.min(this.totalItemCount, end + overscan);
934
+
935
+ // AICODE-NOTE: Check which items in the range are missing
936
+ const missingRanges: Array<{ start: number; end: number }> = [];
937
+ let rangeStart = -1;
938
+
939
+ for (let i = expandedStart; i <= expandedEnd; i++) {
940
+ const hasItem = i < this._allItems.length && this._allItems[i] !== undefined;
941
+
942
+ if (!hasItem) {
943
+ if (rangeStart === -1) {
944
+ rangeStart = i;
945
+ }
946
+ } else {
947
+ if (rangeStart !== -1) {
948
+ missingRanges.push({ start: rangeStart, end: i - 1 });
949
+ rangeStart = -1;
950
+ }
951
+ }
952
+ }
953
+
954
+ // AICODE-NOTE: Add final range if it extends to the end
955
+ if (rangeStart !== -1) {
956
+ missingRanges.push({ start: rangeStart, end: expandedEnd });
957
+ }
958
+
959
+ // AICODE-NOTE: Load missing ranges
960
+ for (const range of missingRanges) {
961
+ // AICODE-NOTE: Skip if already loading this range
962
+ const rangeKey = `${range.start}-${range.end}`;
963
+ if (this.loadingRanges.has(rangeKey)) {
964
+ continue;
965
+ }
966
+
967
+ try {
968
+ yield this.loadItemRange(range.start, range.end + 1);
969
+ } catch (error) {
970
+ console.warn('Failed to load item range:', range, error);
971
+ }
972
+ }
973
+ });
974
+
975
+ /**
976
+ * Update scroll position
977
+ */
978
+ updateScrollPosition(position: number): void {
979
+ this.scrollPosition = position;
980
+ }
981
+
982
+ /**
983
+ * Update container size
984
+ */
985
+ updateContainerSize(width: number, height: number): void {
986
+ this.containerSize = { width, height };
987
+
988
+ // Update calculators with new container size
989
+ this.updateLayoutCalculators();
990
+ }
991
+
992
+ // AICODE-NOTE: Layout calculator management
993
+
994
+ /**
995
+ * Update layout calculators with current settings
996
+ */
997
+ private updateLayoutCalculators(): void {
998
+ const { width, height } = this.containerSize;
999
+
1000
+
1001
+
1002
+ if (width > 0 && height > 0) {
1003
+ // Update grid calculator with very flexible constraints for better auto-calculation
1004
+ // AICODE-NOTE: Prioritize fitting multiple items over exact size matching
1005
+ const minWidth = 120; // Absolute minimum for readability
1006
+ const maxWidth = width * 0.95; // Allow up to 95% of container width
1007
+
1008
+ // console.log('📐 [MODEL] Grid constraints:', {
1009
+ // container: `${width}x${height}`,
1010
+ // minWidth,
1011
+ // maxWidth,
1012
+ // customItemWidth: this.customItemWidth,
1013
+ // itemsPerRow: this.itemsPerRow
1014
+ // });
1015
+
1016
+ if (this.gridCalculator) {
1017
+ this.gridCalculator.updateConfig({
1018
+ containerWidth: width,
1019
+ containerHeight: height,
1020
+ itemsPerRow: this.itemsPerRow,
1021
+ minItemWidth: minWidth,
1022
+ maxItemWidth: maxWidth,
1023
+ aspectRatio: this.customItemWidth / this.customItemHeight
1024
+ });
1025
+ } else {
1026
+ this.gridCalculator = createGridCalculator(width, height, {
1027
+ itemsPerRow: this.itemsPerRow,
1028
+ minItemWidth: minWidth,
1029
+ maxItemWidth: maxWidth,
1030
+ aspectRatio: this.customItemWidth / this.customItemHeight
1031
+ });
1032
+ }
1033
+
1034
+ // Update masonry engines with tight spacing for compact layout
1035
+ const gutter = 4; // Tight spacing for dense masonry layout
1036
+
1037
+ // Calculate optimal column width for masonry layout
1038
+ const targetColumns = this.itemsPerRow === 'auto' ?
1039
+ Math.floor(width / 250) || 3 : // Default to ~250px columns
1040
+ (typeof this.itemsPerRow === 'number' ? this.itemsPerRow : 3);
1041
+
1042
+ const availableWidth = width - (gutter * (targetColumns + 1));
1043
+ const columnWidth = Math.floor(availableWidth / targetColumns);
1044
+
1045
+ if (this.verticalMasonryEngine) {
1046
+ this.verticalMasonryEngine.updateConfig({
1047
+ containerWidth: width,
1048
+ columnWidth,
1049
+ gutter,
1050
+ horizontalOrder: false
1051
+ });
1052
+ } else {
1053
+ this.verticalMasonryEngine = createVerticalMasonry(width, columnWidth, gutter);
1054
+ }
1055
+
1056
+ if (this.horizontalMasonryEngine) {
1057
+ this.horizontalMasonryEngine.updateConfig({
1058
+ containerWidth: width,
1059
+ columnWidth,
1060
+ gutter,
1061
+ horizontalOrder: true
1062
+ });
1063
+ } else {
1064
+ this.horizontalMasonryEngine = createHorizontalMasonry(width, columnWidth, gutter);
1065
+ }
1066
+ }
1067
+ }
1068
+
1069
+ /**
1070
+ * Get grid layout for current items
1071
+ */
1072
+ getGridLayout(): { layout: GridItemLayout[]; totalHeight: number } | null {
1073
+ // Don't call updateLayoutCalculators() here — it modifies observables and
1074
+ // would violate MobX strict mode when called during render.
1075
+
1076
+ if (this.gridCalculator) {
1077
+ const result = this.gridCalculator.calculateLayout(this.items.length);
1078
+ return {
1079
+ layout: result.items,
1080
+ totalHeight: result.totalHeight
1081
+ };
1082
+ }
1083
+
1084
+ return null;
1085
+ }
1086
+
1087
+ /**
1088
+ * Check whether an item is an actual image that should get variable height
1089
+ * in masonry layout. Non-image items (folders, docs, code files) get uniform height.
1090
+ */
1091
+ private isImageItem(item: ListItemData): boolean {
1092
+ // Has an explicit aspect ratio set on the item data
1093
+ if (item.aspectRatio) return true;
1094
+ // Has a thumbnail URL (provider-supplied image)
1095
+ if (item.thumbnailUrl) return true;
1096
+ // Has an image URL
1097
+ if (item.imageUrl) return true;
1098
+ // Provider reports a measured aspect ratio for this path
1099
+ if (this.provider.getAspectRatio?.(item.path) !== undefined) return true;
1100
+ return false;
1101
+ }
1102
+
1103
+ /**
1104
+ * Get vertical masonry layout for current items
1105
+ */
1106
+ getVerticalMasonryLayout(): { layout: MasonryItemPosition[]; totalHeight: number } | null {
1107
+ // Don't call updateLayoutCalculators() here — it modifies observables and
1108
+ // would violate MobX strict mode when called during render. The engine is
1109
+ // created by updateContainerSize() which runs in useEffect.
1110
+
1111
+ if (this.verticalMasonryEngine) {
1112
+ // Convert items to masonry data.
1113
+ // Only images get variable height based on aspect ratio.
1114
+ // Non-image items (folders, documents, etc.) get a uniform fixed height.
1115
+ const columnWidth = this.verticalMasonryEngine?.columnWidth || 250;
1116
+ const fixedNonImageHeight = Math.round(columnWidth * 0.75); // 4:3 aspect for uniform tiles
1117
+
1118
+ const masonryItems: MasonryItem[] = this.items.map(item => {
1119
+ const width = columnWidth;
1120
+
1121
+ if (this.isImageItem(item)) {
1122
+ const aspectRatio = item.aspectRatio
1123
+ || this.provider.getAspectRatio?.(item.path)
1124
+ || this.generateDeterministicAspectRatio(item.id);
1125
+ const height = Math.round(width / aspectRatio);
1126
+ return { id: item.id, width, height, aspectRatio };
1127
+ }
1128
+
1129
+ // Non-image: fixed uniform height
1130
+ return { id: item.id, width, height: fixedNonImageHeight };
1131
+ });
1132
+
1133
+ const result = this.verticalMasonryEngine.calculateLayout(masonryItems);
1134
+ return {
1135
+ layout: result.items,
1136
+ totalHeight: result.totalHeight
1137
+ };
1138
+ }
1139
+
1140
+ return null;
1141
+ }
1142
+
1143
+ /**
1144
+ * Get horizontal masonry layout for current items
1145
+ */
1146
+ getHorizontalMasonryLayout(): { layout: MasonryItemPosition[]; totalHeight: number } | null {
1147
+ // Don't call updateLayoutCalculators() here — same reason as vertical.
1148
+
1149
+ if (this.horizontalMasonryEngine) {
1150
+ // Convert items to masonry data.
1151
+ // Only images get variable height; non-image items get uniform height.
1152
+ const columnWidth = this.horizontalMasonryEngine?.columnWidth || 250;
1153
+ const fixedNonImageHeight = Math.round(columnWidth * 0.75); // 4:3 aspect for uniform tiles
1154
+
1155
+ const masonryItems: MasonryItem[] = this.items.map(item => {
1156
+ const width = columnWidth;
1157
+
1158
+ if (this.isImageItem(item)) {
1159
+ const aspectRatio = item.aspectRatio
1160
+ || this.provider.getAspectRatio?.(item.path)
1161
+ || this.generateDeterministicAspectRatio(item.id);
1162
+ const height = Math.round(width / aspectRatio);
1163
+ return { id: item.id, width, height, aspectRatio };
1164
+ }
1165
+
1166
+ // Non-image: fixed uniform height
1167
+ return { id: item.id, width, height: fixedNonImageHeight };
1168
+ });
1169
+
1170
+ const result = this.horizontalMasonryEngine.calculateLayout(masonryItems);
1171
+ return {
1172
+ layout: result.items,
1173
+ totalHeight: result.totalHeight
1174
+ };
1175
+ }
1176
+
1177
+ return null;
1178
+ }
1179
+
1180
+ /**
1181
+ * Get visible items for virtualization
1182
+ */
1183
+ getVisibleGridItems(scrollTop: number, viewportHeight: number): GridItemLayout[] {
1184
+ if (!this.gridCalculator) return [];
1185
+
1186
+ const result = this.gridCalculator.calculateVisibleItems(
1187
+ this.items.length,
1188
+ scrollTop,
1189
+ viewportHeight
1190
+ );
1191
+
1192
+ return result.visibleItems;
1193
+ }
1194
+
1195
+ /**
1196
+ * Get visible vertical masonry items for virtualization
1197
+ */
1198
+ getVisibleVerticalMasonryItems(scrollTop: number, viewportHeight: number): MasonryItemPosition[] {
1199
+ const layout = this.getVerticalMasonryLayout();
1200
+ if (!layout) return [];
1201
+
1202
+ // Filter items that are visible in the viewport
1203
+ return layout.layout.filter(item => {
1204
+ const itemTop = item.y;
1205
+ const itemBottom = item.y + item.height;
1206
+ const viewportTop = scrollTop;
1207
+ const viewportBottom = scrollTop + viewportHeight;
1208
+
1209
+ return itemBottom >= viewportTop && itemTop <= viewportBottom;
1210
+ });
1211
+ }
1212
+
1213
+ /**
1214
+ * Get visible horizontal masonry items for virtualization
1215
+ */
1216
+ getVisibleHorizontalMasonryItems(scrollTop: number, viewportHeight: number): MasonryItemPosition[] {
1217
+ const layout = this.getHorizontalMasonryLayout();
1218
+ if (!layout) return [];
1219
+
1220
+ // Filter items that are visible in the viewport
1221
+ return layout.layout.filter(item => {
1222
+ const itemTop = item.y;
1223
+ const itemBottom = item.y + item.height;
1224
+ const viewportTop = scrollTop;
1225
+ const viewportBottom = scrollTop + viewportHeight;
1226
+
1227
+ return itemBottom >= viewportTop && itemTop <= viewportBottom;
1228
+ });
1229
+ }
1230
+
1231
+ /**
1232
+ * Get visible masonry items for virtualization (unified method)
1233
+ */
1234
+ getVisibleMasonryItems(scrollTop: number, viewportHeight: number, isHorizontal: boolean = false, overscan: number = 100): MasonryItemPosition[] {
1235
+ const layout = isHorizontal ? this.getHorizontalMasonryLayout() : this.getVerticalMasonryLayout();
1236
+ if (!layout) return [];
1237
+
1238
+ // Add overscan to viewport for smoother scrolling
1239
+ const viewportTop = scrollTop - overscan;
1240
+ const viewportBottom = scrollTop + viewportHeight + overscan;
1241
+
1242
+ // Filter items that intersect with the extended viewport
1243
+ return layout.layout.filter(item => {
1244
+ const itemTop = item.y;
1245
+ const itemBottom = item.y + item.height;
1246
+
1247
+ return itemBottom >= viewportTop && itemTop <= viewportBottom;
1248
+ });
1249
+ }
1250
+
1251
+ // =====================
1252
+ // Imperative Methods
1253
+ // =====================
1254
+
1255
+ /**
1256
+ * Ensure item is visible (scroll to item)
1257
+ */
1258
+ ensureItemVisible(itemId: string): boolean {
1259
+ const index = this.items.findIndex(item => item.id === itemId);
1260
+ if (index === -1) return false;
1261
+
1262
+ this.scrollToItemId = itemId;
1263
+ return true;
1264
+ }
1265
+
1266
+ clearScrollToItem(): void {
1267
+ this.scrollToItemId = null;
1268
+ }
1269
+
1270
+ /**
1271
+ * Get item by ID
1272
+ */
1273
+ getItem(itemId: string): ListItemData | undefined {
1274
+ return this.itemMap.get(itemId);
1275
+ }
1276
+
1277
+ /**
1278
+ * Get item index
1279
+ */
1280
+ getItemIndex(itemId: string): number {
1281
+ return this.items.findIndex(item => item.id === itemId);
1282
+ }
1283
+
1284
+ /**
1285
+ * Resolve thumbnail URL for an item via provider cache.
1286
+ * Returns blob URL when ready, undefined while loading.
1287
+ * MobX observer components re-render when the URL becomes available.
1288
+ */
1289
+ resolveThumbnailUrl(item: ListItemData): string | undefined {
1290
+ return this.provider.resolveThumbnailUrl?.(item);
1291
+ }
1292
+
1293
+ // AICODE-NOTE: Should have functionality like ensure visible etc.
1294
+ // AICODE-NOTE: Should have support for selection, focus, etc.
1295
+ // AICODE-NOTE: Use similar approach as in TreeComponent for context menu, icons, multi-select, etc.
1296
+ // AICODE-NOTE: Should support custom item drawing
1297
+ // AICODE-NOTE: Should have ellipsis for long text
1298
+
1299
+ // AICODE-NOTE: Should have different sizes for thumbnail(grid) view
1300
+ // AICODE-NOTE: thumbnail should be from N per line until 1 per line
1301
+ }