@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,709 @@
1
+ import React, { useRef, useEffect, useMemo, useState } from 'react';
2
+ import { observer } from 'mobx-react-lite';
3
+ import {
4
+ layoutSquarefy,
5
+ createSurfaces,
6
+ drawToImageData,
7
+ Rectangle,
8
+ } from '@cushiontreemap/core';
9
+ import type { INode, Color } from '@cushiontreemap/core';
10
+ import { ListItemsModel } from '../models/ListItemsModel';
11
+ import {
12
+ TreemapModel,
13
+ formatBytes,
14
+ getColorForExtension,
15
+ getHexColorForExtension,
16
+ EXTENSION_LEGEND,
17
+ EXTENSION_CATEGORIES,
18
+ } from '../models/TreemapModel';
19
+ import type { ListItemData } from '../types/ListTypes';
20
+ import type { TreemapNodeData, TreemapScanProgress } from '../providers/ListItemsProvider';
21
+
22
+ /** Node metadata for overlay tiles */
23
+ export interface TileInfo {
24
+ path: string;
25
+ name: string;
26
+ size?: number;
27
+ isDirectory: boolean;
28
+ }
29
+
30
+ export interface TreemapViewProps {
31
+ model: ListItemsModel;
32
+ treemapModel: TreemapModel;
33
+ width: number;
34
+ height: number;
35
+ className?: string;
36
+ onTileContextMenu?: (info: TileInfo, event: React.MouseEvent) => void;
37
+ onEmptyContextMenu?: (event: React.MouseEvent) => void;
38
+ }
39
+
40
+ /** Minimum pixel area for a tile to get an interactive overlay div */
41
+ const MIN_TILE_AREA = 50;
42
+
43
+ /** Minimum dimensions for showing file name label */
44
+ const MIN_LABEL_WIDTH = 60;
45
+ const MIN_LABEL_HEIGHT = 24;
46
+
47
+ /** Minimum dimensions for showing size label */
48
+ const MIN_SIZE_LABEL_WIDTH = 80;
49
+ const MIN_SIZE_LABEL_HEIGHT = 40;
50
+
51
+ /** Base height for legend bar */
52
+ const LEGEND_HEIGHT = 28;
53
+
54
+ /** Height for breadcrumb bar */
55
+ const BREADCRUMB_HEIGHT = 28;
56
+
57
+ /**
58
+ * Map extension category labels to their extension sets for legend highlighting.
59
+ */
60
+ const CATEGORY_EXTENSIONS: Record<string, Set<string>> = {
61
+ 'JS/TS': new Set(['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs']),
62
+ 'Styles': new Set(['css', 'scss', 'less', 'sass']),
63
+ 'Markup': new Set(['html', 'xml', 'svg', 'vue']),
64
+ 'Data': new Set(['json', 'yaml', 'yml', 'toml', 'env']),
65
+ 'Images': new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico']),
66
+ 'Docs': new Set(['md', 'txt', 'pdf', 'doc', 'docx']),
67
+ 'Archives': new Set(['zip', 'tar', 'gz', 'br', 'rar', '7z']),
68
+ };
69
+
70
+ /**
71
+ * Check if a file matches a given extension category label.
72
+ */
73
+ function matchesExtensionCategory(name: string, isDirectory: boolean, category: string): boolean {
74
+ if (category === 'Folders') return isDirectory;
75
+ if (category === 'Other') {
76
+ if (isDirectory) return false;
77
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
78
+ return !EXTENSION_CATEGORIES[ext];
79
+ }
80
+ const exts = CATEGORY_EXTENSIONS[category];
81
+ if (!exts) return false;
82
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
83
+ return exts.has(ext);
84
+ }
85
+
86
+ /**
87
+ * Compute recursive size for a TreemapNodeData (sum of all descendant file sizes).
88
+ */
89
+ function computeSize(node: TreemapNodeData): number {
90
+ if (!node.isDirectory || !node.children || node.children.length === 0) {
91
+ return node.size ?? 1;
92
+ }
93
+ return node.children.reduce((sum, child) => sum + computeSize(child), 0);
94
+ }
95
+
96
+ /**
97
+ * Build an INode tree from recursive TreemapNodeData.
98
+ * Directories become internal nodes with children; files become leaves.
99
+ */
100
+ function buildNestedTree(nodes: TreemapNodeData[]): {
101
+ root: INode<string>;
102
+ infoMap: Map<string, TileInfo>;
103
+ } {
104
+ const infoMap = new Map<string, TileInfo>();
105
+
106
+ function convertNode(node: TreemapNodeData): INode<string> {
107
+ const size = computeSize(node);
108
+ infoMap.set(node.path, {
109
+ path: node.path,
110
+ name: node.name,
111
+ size: node.size,
112
+ isDirectory: node.isDirectory,
113
+ });
114
+
115
+ if (node.isDirectory && node.children && node.children.length > 0) {
116
+ const children = node.children
117
+ .map(child => convertNode(child))
118
+ .filter(c => (c.value ?? 0) > 0)
119
+ .sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
120
+
121
+ return {
122
+ id: node.path,
123
+ value: size,
124
+ children: children.length > 0 ? children : undefined,
125
+ };
126
+ }
127
+
128
+ return {
129
+ id: node.path,
130
+ value: size,
131
+ };
132
+ }
133
+
134
+ const children = nodes
135
+ .map(n => convertNode(n))
136
+ .filter(c => (c.value ?? 0) > 0)
137
+ .sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
138
+
139
+ const root: INode<string> = {
140
+ id: '__root__',
141
+ value: children.reduce((sum, c) => sum + (c.value ?? 0), 0),
142
+ children,
143
+ };
144
+
145
+ return { root, infoMap };
146
+ }
147
+
148
+ /**
149
+ * Build flat INode tree from ListItemData (fallback when no recursive data).
150
+ */
151
+ function buildFlatTree(items: ListItemData[]): {
152
+ root: INode<string>;
153
+ infoMap: Map<string, TileInfo>;
154
+ } {
155
+ const infoMap = new Map<string, TileInfo>();
156
+
157
+ const children: INode<string>[] = items
158
+ .filter(item => (item.size ?? 1) > 0)
159
+ .map(item => {
160
+ infoMap.set(item.id, {
161
+ path: item.path,
162
+ name: item.name,
163
+ size: item.size,
164
+ isDirectory: !!item.isDirectory,
165
+ });
166
+ return {
167
+ id: item.id,
168
+ value: item.size ?? 1,
169
+ };
170
+ })
171
+ .sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
172
+
173
+ const root: INode<string> = {
174
+ id: '__root__',
175
+ value: children.reduce((sum, c) => sum + (c.value ?? 0), 0),
176
+ children,
177
+ };
178
+
179
+ return { root, infoMap };
180
+ }
181
+
182
+ /**
183
+ * Find a subtree's children in recursive TreemapNodeData by path.
184
+ * Returns the children of the directory at the given path, or null if not found.
185
+ */
186
+ function findSubtree(nodes: TreemapNodeData[], path: string): TreemapNodeData[] | null {
187
+ for (const node of nodes) {
188
+ if (node.path === path && node.isDirectory && node.children) {
189
+ return node.children;
190
+ }
191
+ if (node.children) {
192
+ const found = findSubtree(node.children, path);
193
+ if (found) return found;
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /** Find a node by ID in the INode tree */
200
+ function findNode(node: INode<string>, id: string): INode<string> | null {
201
+ if (node.id === id) return node;
202
+ if (node.children) {
203
+ for (const child of node.children) {
204
+ const found = findNode(child, id);
205
+ if (found) return found;
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * TreemapView — renders items from ListItemsModel as a cushion-shaded treemap.
213
+ *
214
+ * Uses @cushiontreemap/core to render to a canvas, with transparent overlaid
215
+ * divs for click/hover interactivity.
216
+ *
217
+ * If the provider supports loadTreemapData(), shows recursive subdirectory content.
218
+ */
219
+ export const TreemapView = observer<TreemapViewProps>(({
220
+ model,
221
+ treemapModel,
222
+ width,
223
+ height,
224
+ className = '',
225
+ onTileContextMenu,
226
+ onEmptyContextMenu,
227
+ }) => {
228
+ const canvasRef = useRef<HTMLCanvasElement>(null);
229
+
230
+ // Recursive data loading
231
+ const [recursiveData, setRecursiveData] = useState<TreemapNodeData[] | null>(null);
232
+ const [loading, setLoading] = useState(false);
233
+ const [scanProgress, setScanProgress] = useState<TreemapScanProgress | null>(null);
234
+
235
+ useEffect(() => {
236
+ if (!model.provider.loadTreemapData) return;
237
+ let cancelled = false;
238
+ setLoading(true);
239
+ setScanProgress(null);
240
+ treemapModel.resetZoom();
241
+ model.provider.loadTreemapData((progress) => {
242
+ if (!cancelled) setScanProgress({ ...progress });
243
+ })
244
+ .then(data => { if (!cancelled) setRecursiveData(data); })
245
+ .catch(err => console.warn('[TreemapView] Failed to load recursive data:', err))
246
+ .finally(() => { if (!cancelled) { setLoading(false); setScanProgress(null); } });
247
+ return () => { cancelled = true; };
248
+ }, [model.provider, model.items]); // reload when items change (path navigation)
249
+
250
+ // Compute chrome height (legend + optional breadcrumb)
251
+ const hasBreadcrumbs = treemapModel.breadcrumbs.length > 0;
252
+ const chromeHeight = LEGEND_HEIGHT + (hasBreadcrumbs ? BREADCRUMB_HEIGHT : 0);
253
+
254
+ // Compute available canvas dimensions (subtract chrome)
255
+ const canvasWidth = Math.max(1, Math.floor(width));
256
+ const canvasHeight = Math.max(1, Math.floor(height - chromeHeight));
257
+
258
+ // When zoomed, find subtree from recursive data
259
+ const effectiveData = useMemo(() => {
260
+ if (!recursiveData || !treemapModel.zoomPath) return recursiveData;
261
+ const subtree = findSubtree(recursiveData, treemapModel.zoomPath);
262
+ return subtree ?? recursiveData;
263
+ }, [recursiveData, treemapModel.zoomPath]);
264
+
265
+ // Build the tree — prefer recursive data if available
266
+ const { root, infoMap, rects } = useMemo(() => {
267
+ const items = effectiveData ?? model.items;
268
+ if ((!effectiveData && model.items.length === 0) || (effectiveData && effectiveData.length === 0)) {
269
+ return {
270
+ root: null,
271
+ infoMap: new Map<string, TileInfo>(),
272
+ rects: new Map<string, Rectangle>(),
273
+ };
274
+ }
275
+
276
+ const { root: r, infoMap: im } = effectiveData
277
+ ? buildNestedTree(effectiveData)
278
+ : buildFlatTree(model.items);
279
+
280
+ if (!r.children || r.children.length === 0) {
281
+ return { root: null, infoMap: im, rects: new Map<string, Rectangle>() };
282
+ }
283
+
284
+ const rectMap = layoutSquarefy(r, canvasWidth, canvasHeight);
285
+ return { root: r, infoMap: im, rects: rectMap };
286
+ }, [effectiveData, model.items, canvasWidth, canvasHeight]);
287
+
288
+ // Draw the cushion treemap to the canvas
289
+ useEffect(() => {
290
+ const canvas = canvasRef.current;
291
+ if (!canvas || !root || rects.size === 0) return;
292
+
293
+ canvas.width = canvasWidth;
294
+ canvas.height = canvasHeight;
295
+
296
+ const ctx = canvas.getContext('2d');
297
+ if (!ctx) return;
298
+
299
+ const imageData = ctx.createImageData(canvasWidth, canvasHeight);
300
+
301
+ const surfaces = createSurfaces(root, rects);
302
+
303
+ const getColor = (node: INode<string>): Color => {
304
+ if (node.id === '__root__') return [128, 128, 128, 255];
305
+ const info = infoMap.get(node.id);
306
+ if (!info) return [156, 163, 175, 255];
307
+ return getColorForExtension(info.name, info.isDirectory);
308
+ };
309
+
310
+ drawToImageData(root, rects, surfaces, imageData, getColor, {
311
+ Ia: 40,
312
+ Is: 215,
313
+ Lx: 0.09759,
314
+ Ly: 0.19518,
315
+ Lz: 0.9759,
316
+ });
317
+
318
+ ctx.putImageData(imageData, 0, 0);
319
+
320
+ // Draw border lines between tiles
321
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.15)';
322
+ ctx.lineWidth = 1;
323
+ for (const [id, rect] of rects) {
324
+ if (id === '__root__') continue;
325
+ const x = Math.round(rect.x0) + 0.5;
326
+ const y = Math.round(rect.y0) + 0.5;
327
+ const w = Math.round(rect.x1 - rect.x0);
328
+ const h = Math.round(rect.y1 - rect.y0);
329
+ if (w > 2 && h > 2) {
330
+ ctx.strokeRect(x, y, w, h);
331
+ }
332
+ }
333
+ }, [root, rects, infoMap, canvasWidth, canvasHeight]);
334
+
335
+ // Build overlay tile data — only for leaf nodes (visible tiles)
336
+ const tiles = useMemo(() => {
337
+ if (!root || rects.size === 0) return [];
338
+
339
+ const result: Array<{
340
+ id: string;
341
+ info: TileInfo;
342
+ x: number;
343
+ y: number;
344
+ w: number;
345
+ h: number;
346
+ }> = [];
347
+
348
+ for (const [id, rect] of rects) {
349
+ if (id === '__root__') continue;
350
+ const info = infoMap.get(id);
351
+ if (!info) continue;
352
+
353
+ const x = rect.x0;
354
+ const y = rect.y0;
355
+ const w = rect.x1 - rect.x0;
356
+ const h = rect.y1 - rect.y0;
357
+
358
+ if (w * h < MIN_TILE_AREA) continue;
359
+
360
+ // Skip internal nodes (directories with children that got subdivided)
361
+ // Only show leaf nodes as interactive tiles
362
+ const node = findNode(root, id);
363
+ if (node && node.children && node.children.length > 0) continue;
364
+
365
+ result.push({ id, info, x, y, w, h });
366
+ }
367
+
368
+ return result;
369
+ }, [root, rects, infoMap]);
370
+
371
+ // Event handlers
372
+ const handleTileClick = (info: TileInfo, event: React.MouseEvent) => {
373
+ // For directories, zoom in on single click (instead of navigating away)
374
+ if (info.isDirectory) {
375
+ treemapModel.zoomIn(info.path);
376
+ return;
377
+ }
378
+ // For files, select
379
+ const item = model.items.find(i => i.path === info.path || i.id === info.path);
380
+ if (item) {
381
+ model.selectItem(item, {
382
+ ctrl: event.ctrlKey || event.metaKey,
383
+ shift: event.shiftKey,
384
+ });
385
+ }
386
+ };
387
+
388
+ const handleTileDoubleClick = (info: TileInfo) => {
389
+ if (!model.provider.onItemDoubleClick) return;
390
+ const item = model.items.find(i => i.path === info.path || i.id === info.path);
391
+ if (item) {
392
+ model.provider.onItemDoubleClick(item);
393
+ } else {
394
+ // For recursive items not in the flat list, create a fake item
395
+ const fakeItem: ListItemData = {
396
+ id: info.path,
397
+ name: info.name,
398
+ path: info.path,
399
+ type: info.isDirectory ? 'directory' : 'file',
400
+ isDirectory: info.isDirectory,
401
+ size: info.size,
402
+ };
403
+ model.provider.onItemDoubleClick(fakeItem);
404
+ }
405
+ };
406
+
407
+ const handleTileContextMenu = (info: TileInfo, event: React.MouseEvent) => {
408
+ event.preventDefault();
409
+ event.stopPropagation();
410
+ onTileContextMenu?.(info, event);
411
+ };
412
+
413
+ const handleEmptyContextMenu = (event: React.MouseEvent) => {
414
+ event.preventDefault();
415
+ onEmptyContextMenu?.(event);
416
+ };
417
+
418
+ const handleTileMouseEnter = (id: string) => {
419
+ treemapModel.setHoveredPath(id);
420
+ };
421
+
422
+ const handleTileMouseLeave = () => {
423
+ treemapModel.setHoveredPath(null);
424
+ };
425
+
426
+ // Tooltip data
427
+ const hoveredInfo = treemapModel.hoveredPath
428
+ ? infoMap.get(treemapModel.hoveredPath)
429
+ : null;
430
+ const hoveredRect = treemapModel.hoveredPath
431
+ ? rects.get(treemapModel.hoveredPath)
432
+ : null;
433
+
434
+ const totalSize = useMemo(() => root?.value ?? 0, [root]);
435
+
436
+ // Extension highlighting
437
+ const highlighted = treemapModel.highlightedExtension;
438
+
439
+ if (loading) {
440
+ return (
441
+ <div className={`flex items-center justify-center text-muted-foreground ${className}`} style={{ width, height }}>
442
+ <div className="flex flex-col items-center gap-3">
443
+ <div className="w-6 h-6 border-2 border-muted-foreground/40 border-t-muted-foreground rounded-full animate-spin" />
444
+ <p className="text-sm font-medium">Scanning directories...</p>
445
+ {scanProgress && (
446
+ <div className="flex items-center gap-4 text-xs">
447
+ <span>{scanProgress.directoriesScanned} folders</span>
448
+ <span>{scanProgress.filesFound} files</span>
449
+ {scanProgress.totalSize > 0 && (
450
+ <span>{formatBytes(scanProgress.totalSize)}</span>
451
+ )}
452
+ </div>
453
+ )}
454
+ </div>
455
+ </div>
456
+ );
457
+ }
458
+
459
+ if (model.items.length === 0 && !effectiveData) {
460
+ return (
461
+ <div className={`flex items-center justify-center text-muted-foreground ${className}`} style={{ width, height }}>
462
+ <p className="text-sm">No items to display</p>
463
+ </div>
464
+ );
465
+ }
466
+
467
+ return (
468
+ <div className={`flex flex-col select-none ${className}`} style={{ width, height }}>
469
+ {/* Legend bar — interactive: click to highlight extension category */}
470
+ <div className="flex items-center gap-3 px-3 py-1 border-b bg-background/80 flex-shrink-0 overflow-x-auto" style={{ height: LEGEND_HEIGHT }}>
471
+ <span className="text-xs font-medium text-muted-foreground whitespace-nowrap">Filter:</span>
472
+ {EXTENSION_LEGEND.map(({ label, color }) => {
473
+ const isActive = highlighted === label;
474
+ return (
475
+ <button
476
+ key={label}
477
+ type="button"
478
+ onClick={() => treemapModel.setHighlightedExtension(label)}
479
+ className={`flex items-center gap-1 whitespace-nowrap rounded px-1 py-0.5 transition-colors ${
480
+ isActive
481
+ ? 'bg-accent ring-1 ring-accent-foreground/20'
482
+ : 'hover:bg-accent/50'
483
+ }`}
484
+ >
485
+ <div
486
+ className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
487
+ style={{ backgroundColor: color }}
488
+ />
489
+ <span className={`text-[10px] ${isActive ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>
490
+ {label}
491
+ </span>
492
+ </button>
493
+ );
494
+ })}
495
+ <div className="ml-auto text-[10px] text-muted-foreground whitespace-nowrap">
496
+ {infoMap.size} items • {formatBytes(totalSize)}
497
+ </div>
498
+ </div>
499
+
500
+ {/* Breadcrumb bar — only when zoomed */}
501
+ {hasBreadcrumbs && (
502
+ <div className="flex items-center gap-1 px-3 border-b bg-background/60 flex-shrink-0 overflow-x-auto" style={{ height: BREADCRUMB_HEIGHT }}>
503
+ <button
504
+ type="button"
505
+ onClick={() => treemapModel.resetZoom()}
506
+ className="text-[11px] text-primary hover:underline"
507
+ >
508
+ Root
509
+ </button>
510
+ {treemapModel.breadcrumbs.map((crumb) => (
511
+ <React.Fragment key={crumb.path}>
512
+ <span className="text-[10px] text-muted-foreground">/</span>
513
+ <button
514
+ type="button"
515
+ onClick={() => treemapModel.zoomIn(crumb.path)}
516
+ className={`text-[11px] ${
517
+ crumb.path === treemapModel.zoomPath
518
+ ? 'text-foreground font-medium'
519
+ : 'text-primary hover:underline'
520
+ }`}
521
+ >
522
+ {crumb.name}
523
+ </button>
524
+ </React.Fragment>
525
+ ))}
526
+ </div>
527
+ )}
528
+
529
+ {/* Treemap canvas + overlay container */}
530
+ <div
531
+ className="relative flex-1 overflow-hidden"
532
+ style={{ width: canvasWidth, height: canvasHeight }}
533
+ onContextMenu={handleEmptyContextMenu}
534
+ >
535
+ {/* Canvas layer — the cushion-shaded treemap */}
536
+ <canvas
537
+ ref={canvasRef}
538
+ width={canvasWidth}
539
+ height={canvasHeight}
540
+ className="absolute inset-0"
541
+ style={{ imageRendering: 'auto' }}
542
+ />
543
+
544
+ {/* Interactive overlay layer */}
545
+ {tiles.map(({ id, info, x, y, w, h }) => {
546
+ const isSelected = model.isItemSelected(info.path);
547
+ const isHovered = treemapModel.hoveredPath === id;
548
+ const showLabel = w >= MIN_LABEL_WIDTH && h >= MIN_LABEL_HEIGHT;
549
+ const showSize = w >= MIN_SIZE_LABEL_WIDTH && h >= MIN_SIZE_LABEL_HEIGHT;
550
+
551
+ // Extension highlighting: dim non-matching tiles
552
+ const isDimmed = highlighted != null && !matchesExtensionCategory(info.name, info.isDirectory, highlighted);
553
+
554
+ return (
555
+ <div
556
+ key={id}
557
+ className="absolute cursor-pointer"
558
+ style={{
559
+ left: x,
560
+ top: y,
561
+ width: w,
562
+ height: h,
563
+ opacity: isDimmed ? 0.3 : 1,
564
+ transition: 'opacity 0.15s ease',
565
+ }}
566
+ onClick={(e) => handleTileClick(info, e)}
567
+ onDoubleClick={() => handleTileDoubleClick(info)}
568
+ onContextMenu={(e) => handleTileContextMenu(info, e)}
569
+ onMouseEnter={() => handleTileMouseEnter(id)}
570
+ onMouseLeave={handleTileMouseLeave}
571
+ aria-label={`${info.name}${info.size ? ` (${formatBytes(info.size)})` : ''}`}
572
+ >
573
+ {/* Selection / hover highlight */}
574
+ {(isSelected || isHovered) && (
575
+ <div
576
+ className="absolute inset-0 pointer-events-none"
577
+ style={{
578
+ backgroundColor: isSelected
579
+ ? 'rgba(255, 255, 255, 0.25)'
580
+ : 'rgba(255, 255, 255, 0.12)',
581
+ outline: isSelected
582
+ ? '2px solid rgba(59, 130, 246, 0.8)'
583
+ : '1px solid rgba(255, 255, 255, 0.4)',
584
+ outlineOffset: isSelected ? '-2px' : '-1px',
585
+ zIndex: isSelected ? 2 : 1,
586
+ }}
587
+ />
588
+ )}
589
+
590
+ {/* Text label overlay */}
591
+ {showLabel && treemapModel.showLabels && (
592
+ <div
593
+ className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none overflow-hidden px-1"
594
+ style={{ zIndex: 3 }}
595
+ >
596
+ <span
597
+ className="text-white font-medium truncate max-w-full text-center leading-tight"
598
+ style={{
599
+ fontSize: w > 150 && h > 60 ? '12px' : '10px',
600
+ textShadow: '0 1px 3px rgba(0,0,0,0.8), 0 0px 1px rgba(0,0,0,0.9)',
601
+ }}
602
+ >
603
+ {info.name}
604
+ </span>
605
+ {showSize && treemapModel.showSizes && info.size != null && info.size > 0 && (
606
+ <span
607
+ className="text-white/80 truncate max-w-full text-center leading-tight"
608
+ style={{
609
+ fontSize: '9px',
610
+ textShadow: '0 1px 2px rgba(0,0,0,0.8)',
611
+ }}
612
+ >
613
+ {formatBytes(info.size)}
614
+ </span>
615
+ )}
616
+ </div>
617
+ )}
618
+ </div>
619
+ );
620
+ })}
621
+
622
+ {/* Tooltip */}
623
+ {hoveredInfo && hoveredRect && (
624
+ <TreemapTooltip
625
+ info={hoveredInfo}
626
+ rect={hoveredRect}
627
+ canvasWidth={canvasWidth}
628
+ canvasHeight={canvasHeight}
629
+ totalSize={totalSize}
630
+ />
631
+ )}
632
+ </div>
633
+ </div>
634
+ );
635
+ });
636
+
637
+ TreemapView.displayName = 'TreemapView';
638
+
639
+ /** Floating tooltip for hovered treemap tile */
640
+ const TreemapTooltip = observer<{
641
+ info: TileInfo;
642
+ rect: Rectangle;
643
+ canvasWidth: number;
644
+ canvasHeight: number;
645
+ totalSize: number;
646
+ }>(({ info, rect, canvasWidth, canvasHeight, totalSize }) => {
647
+ const centerX = (rect.x0 + rect.x1) / 2;
648
+ const centerY = rect.y0;
649
+
650
+ const tooltipWidth = 200;
651
+ const tooltipHeight = 80;
652
+
653
+ let left = centerX - tooltipWidth / 2;
654
+ let top = centerY - tooltipHeight - 8;
655
+
656
+ if (left < 4) left = 4;
657
+ if (left + tooltipWidth > canvasWidth - 4) left = canvasWidth - tooltipWidth - 4;
658
+ if (top < 4) {
659
+ top = rect.y1 + 8;
660
+ }
661
+ if (top + tooltipHeight > canvasHeight - 4) {
662
+ top = canvasHeight - tooltipHeight - 4;
663
+ }
664
+
665
+ const ext = info.name.split('.').pop()?.toLowerCase() ?? '';
666
+ const percentage = totalSize > 0 && info.size
667
+ ? ((info.size / totalSize) * 100).toFixed(1)
668
+ : null;
669
+ const colorHex = getHexColorForExtension(info.name, info.isDirectory);
670
+
671
+ return (
672
+ <div
673
+ className="absolute z-50 pointer-events-none rounded-md border bg-popover/95 px-3 py-2 shadow-lg backdrop-blur-sm"
674
+ style={{
675
+ left,
676
+ top,
677
+ width: tooltipWidth,
678
+ }}
679
+ >
680
+ <div className="flex items-center gap-2 mb-1">
681
+ <div
682
+ className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
683
+ style={{ backgroundColor: colorHex }}
684
+ />
685
+ <span className="text-xs font-medium text-foreground truncate">{info.name}</span>
686
+ </div>
687
+ <div className="text-[10px] text-muted-foreground space-y-0.5">
688
+ <div className="flex justify-between">
689
+ <span>Type</span>
690
+ <span>{info.isDirectory ? 'Directory' : (ext ? `.${ext}` : 'File')}</span>
691
+ </div>
692
+ {info.size != null && (
693
+ <div className="flex justify-between">
694
+ <span>Size</span>
695
+ <span>{formatBytes(info.size)}</span>
696
+ </div>
697
+ )}
698
+ {percentage && (
699
+ <div className="flex justify-between">
700
+ <span>Share</span>
701
+ <span>{percentage}%</span>
702
+ </div>
703
+ )}
704
+ </div>
705
+ </div>
706
+ );
707
+ });
708
+
709
+ TreemapTooltip.displayName = 'TreemapTooltip';