@mseep/anything-analyzer 3.6.50

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 (172) hide show
  1. package/.codeartsdoer/.codebaseignore +0 -0
  2. package/.codeartsdoer/AGENTS.md +12 -0
  3. package/.github/workflows/build.yml +146 -0
  4. package/README.en.md +264 -0
  5. package/README.md +276 -0
  6. package/RELEASE_NOTES.md +16 -0
  7. package/USAGE.md +490 -0
  8. package/color-preview-r3.html +414 -0
  9. package/color-preview.html +414 -0
  10. package/dev-app-update.yml +3 -0
  11. package/electron-builder.yml +36 -0
  12. package/electron.vite.config.ts +40 -0
  13. package/package.json +53 -0
  14. package/report-2026-04-13-copilot-claude-sonnet-4.6.md +955 -0
  15. package/resources/doloffer-logo.png +0 -0
  16. package/resources/entitlements.mac.plist +12 -0
  17. package/resources/icon.ico +0 -0
  18. package/resources/icon.png +0 -0
  19. package/src/main/ai/ai-analyzer.ts +517 -0
  20. package/src/main/ai/crypto-script-extractor.ts +206 -0
  21. package/src/main/ai/data-assembler.ts +205 -0
  22. package/src/main/ai/llm-router.ts +1120 -0
  23. package/src/main/ai/prompt-builder.ts +349 -0
  24. package/src/main/ai/scene-detector.ts +302 -0
  25. package/src/main/capture/capture-engine.ts +130 -0
  26. package/src/main/capture/interaction-recorder.ts +171 -0
  27. package/src/main/capture/js-injector.ts +57 -0
  28. package/src/main/capture/replay-engine.ts +256 -0
  29. package/src/main/capture/storage-collector.ts +76 -0
  30. package/src/main/cdp/cdp-manager.ts +233 -0
  31. package/src/main/db/database.ts +41 -0
  32. package/src/main/db/migrations.ts +235 -0
  33. package/src/main/db/repositories.ts +574 -0
  34. package/src/main/fingerprint/http-spoofing.ts +48 -0
  35. package/src/main/fingerprint/presets.ts +173 -0
  36. package/src/main/fingerprint/profile-generator.ts +115 -0
  37. package/src/main/fingerprint/profile-store.ts +52 -0
  38. package/src/main/index.ts +260 -0
  39. package/src/main/ipc.ts +856 -0
  40. package/src/main/logger.ts +42 -0
  41. package/src/main/mcp/mcp-config.ts +66 -0
  42. package/src/main/mcp/mcp-manager.ts +155 -0
  43. package/src/main/mcp/mcp-server.ts +1038 -0
  44. package/src/main/prompt-templates.ts +170 -0
  45. package/src/main/proxy/ca-manager.ts +204 -0
  46. package/src/main/proxy/cert-download-page.ts +171 -0
  47. package/src/main/proxy/cert-installer.ts +242 -0
  48. package/src/main/proxy/mitm-proxy-config.ts +37 -0
  49. package/src/main/proxy/mitm-proxy-server.ts +1085 -0
  50. package/src/main/proxy/system-proxy.ts +248 -0
  51. package/src/main/session/session-manager.ts +724 -0
  52. package/src/main/tab-manager.ts +582 -0
  53. package/src/main/updater.ts +111 -0
  54. package/src/main/window.ts +235 -0
  55. package/src/preload/hook-script.ts +270 -0
  56. package/src/preload/index.ts +211 -0
  57. package/src/preload/interaction-hook.ts +286 -0
  58. package/src/preload/stealth-script.ts +302 -0
  59. package/src/preload/target-preload.ts +15 -0
  60. package/src/renderer/App.tsx +656 -0
  61. package/src/renderer/components/AiLogDetail.tsx +173 -0
  62. package/src/renderer/components/AiLogList.tsx +101 -0
  63. package/src/renderer/components/AiLogView.module.css +364 -0
  64. package/src/renderer/components/AiLogView.tsx +86 -0
  65. package/src/renderer/components/AnalyzeBar.module.css +79 -0
  66. package/src/renderer/components/AnalyzeBar.tsx +104 -0
  67. package/src/renderer/components/BrowserPanel.module.css +67 -0
  68. package/src/renderer/components/BrowserPanel.tsx +90 -0
  69. package/src/renderer/components/ControlBar.module.css +47 -0
  70. package/src/renderer/components/ControlBar.tsx +205 -0
  71. package/src/renderer/components/HookLog.tsx +132 -0
  72. package/src/renderer/components/InteractionLog.tsx +183 -0
  73. package/src/renderer/components/MCPServerModal.tsx +427 -0
  74. package/src/renderer/components/PromptTemplateModal.tsx +254 -0
  75. package/src/renderer/components/ReportView.module.css +413 -0
  76. package/src/renderer/components/ReportView.tsx +429 -0
  77. package/src/renderer/components/RequestDetail.module.css +191 -0
  78. package/src/renderer/components/RequestDetail.tsx +202 -0
  79. package/src/renderer/components/RequestLog.module.css +69 -0
  80. package/src/renderer/components/RequestLog.tsx +208 -0
  81. package/src/renderer/components/SessionList.module.css +245 -0
  82. package/src/renderer/components/SessionList.tsx +247 -0
  83. package/src/renderer/components/SettingsModal.tsx +100 -0
  84. package/src/renderer/components/StatusBar.module.css +44 -0
  85. package/src/renderer/components/StatusBar.tsx +102 -0
  86. package/src/renderer/components/StorageView.module.css +41 -0
  87. package/src/renderer/components/StorageView.tsx +178 -0
  88. package/src/renderer/components/TabBar.module.css +88 -0
  89. package/src/renderer/components/TabBar.tsx +70 -0
  90. package/src/renderer/components/Titlebar.module.css +254 -0
  91. package/src/renderer/components/Titlebar.tsx +169 -0
  92. package/src/renderer/components/settings/FingerprintSection.tsx +198 -0
  93. package/src/renderer/components/settings/GeneralSection.tsx +164 -0
  94. package/src/renderer/components/settings/LLMSection.tsx +148 -0
  95. package/src/renderer/components/settings/MCPServerSection.tsx +136 -0
  96. package/src/renderer/components/settings/MitmProxySection.tsx +320 -0
  97. package/src/renderer/components/settings/ProxySection.tsx +110 -0
  98. package/src/renderer/css-modules.d.ts +4 -0
  99. package/src/renderer/hooks/useCapture.ts +383 -0
  100. package/src/renderer/hooks/useConfirm.tsx +91 -0
  101. package/src/renderer/hooks/useSession.ts +136 -0
  102. package/src/renderer/hooks/useTabs.ts +103 -0
  103. package/src/renderer/i18n/en.ts +167 -0
  104. package/src/renderer/i18n/index.ts +47 -0
  105. package/src/renderer/i18n/zh.ts +170 -0
  106. package/src/renderer/index.html +12 -0
  107. package/src/renderer/main.tsx +15 -0
  108. package/src/renderer/styles/global.css +144 -0
  109. package/src/renderer/styles/themes/ayu-dark.css +59 -0
  110. package/src/renderer/styles/themes/catppuccin.css +59 -0
  111. package/src/renderer/styles/themes/discord.css +59 -0
  112. package/src/renderer/styles/themes/dracula.css +59 -0
  113. package/src/renderer/styles/themes/github-dark.css +59 -0
  114. package/src/renderer/styles/themes/gruvbox.css +59 -0
  115. package/src/renderer/styles/themes/index.css +11 -0
  116. package/src/renderer/styles/themes/light.css +59 -0
  117. package/src/renderer/styles/themes/nord.css +59 -0
  118. package/src/renderer/styles/themes/one-dark.css +59 -0
  119. package/src/renderer/styles/themes/tokyo-night.css +59 -0
  120. package/src/renderer/styles/tokens.css +137 -0
  121. package/src/renderer/theme.ts +31 -0
  122. package/src/renderer/ui/Badge.module.css +38 -0
  123. package/src/renderer/ui/Badge.tsx +36 -0
  124. package/src/renderer/ui/Button.module.css +142 -0
  125. package/src/renderer/ui/Button.tsx +46 -0
  126. package/src/renderer/ui/Collapse.module.css +49 -0
  127. package/src/renderer/ui/Collapse.tsx +57 -0
  128. package/src/renderer/ui/CopyableBlock.module.css +56 -0
  129. package/src/renderer/ui/CopyableBlock.tsx +42 -0
  130. package/src/renderer/ui/Empty.module.css +19 -0
  131. package/src/renderer/ui/Empty.tsx +34 -0
  132. package/src/renderer/ui/Icons.tsx +346 -0
  133. package/src/renderer/ui/Input.module.css +103 -0
  134. package/src/renderer/ui/Input.tsx +94 -0
  135. package/src/renderer/ui/InputNumber.module.css +68 -0
  136. package/src/renderer/ui/InputNumber.tsx +104 -0
  137. package/src/renderer/ui/Modal.module.css +83 -0
  138. package/src/renderer/ui/Modal.tsx +67 -0
  139. package/src/renderer/ui/Popconfirm.module.css +73 -0
  140. package/src/renderer/ui/Popconfirm.tsx +74 -0
  141. package/src/renderer/ui/Progress.module.css +35 -0
  142. package/src/renderer/ui/Progress.tsx +30 -0
  143. package/src/renderer/ui/Select.module.css +91 -0
  144. package/src/renderer/ui/Select.tsx +100 -0
  145. package/src/renderer/ui/Spinner.module.css +44 -0
  146. package/src/renderer/ui/Spinner.tsx +27 -0
  147. package/src/renderer/ui/Switch.module.css +39 -0
  148. package/src/renderer/ui/Switch.tsx +43 -0
  149. package/src/renderer/ui/Tabs.module.css +76 -0
  150. package/src/renderer/ui/Tabs.tsx +53 -0
  151. package/src/renderer/ui/Tag.module.css +66 -0
  152. package/src/renderer/ui/Tag.tsx +47 -0
  153. package/src/renderer/ui/Timeline.module.css +42 -0
  154. package/src/renderer/ui/Timeline.tsx +29 -0
  155. package/src/renderer/ui/Toast.module.css +99 -0
  156. package/src/renderer/ui/Toast.tsx +90 -0
  157. package/src/renderer/ui/Tooltip.module.css +26 -0
  158. package/src/renderer/ui/Tooltip.tsx +23 -0
  159. package/src/renderer/ui/VirtualTable.module.css +230 -0
  160. package/src/renderer/ui/VirtualTable.tsx +416 -0
  161. package/src/renderer/ui/index.ts +55 -0
  162. package/src/shared/types.ts +695 -0
  163. package/tests/main/ai/crypto-script-extractor.test.ts +281 -0
  164. package/tests/main/ai/llm-router.test.ts +1537 -0
  165. package/tests/main/ai/prompt-builder.test.ts +178 -0
  166. package/tests/main/ai/scene-detector.test.ts +212 -0
  167. package/tests/main/db/migrations.test.ts +134 -0
  168. package/tests/main/release-workflow.test.ts +59 -0
  169. package/tsconfig.json +7 -0
  170. package/tsconfig.node.json +23 -0
  171. package/tsconfig.web.json +24 -0
  172. package/vitest.config.ts +13 -0
@@ -0,0 +1,230 @@
1
+ .wrapper {
2
+ display: flex;
3
+ flex-direction: column;
4
+ border: 1px solid var(--color-border);
5
+ border-radius: var(--radius-button);
6
+ overflow: hidden;
7
+ min-height: 0;
8
+ flex: 1;
9
+ }
10
+
11
+ /* Header row */
12
+ .headerRow {
13
+ display: flex;
14
+ align-items: center;
15
+ background: var(--color-surface);
16
+ border-bottom: 1px solid var(--color-border);
17
+ flex-shrink: 0;
18
+ }
19
+
20
+ .headerCell {
21
+ display: flex;
22
+ align-items: center;
23
+ gap: 4px;
24
+ padding: 6px 10px;
25
+ font-size: var(--font-size-xs);
26
+ font-weight: 600;
27
+ color: var(--text-secondary);
28
+ user-select: none;
29
+ border-right: 1px solid var(--color-border-subtle);
30
+ white-space: nowrap;
31
+ flex-shrink: 0;
32
+ }
33
+
34
+ .headerCell:last-child {
35
+ border-right: none;
36
+ }
37
+
38
+ .headerCellFlex {
39
+ composes: headerCell;
40
+ flex: 1;
41
+ min-width: 0;
42
+ }
43
+
44
+ /* Text label inside header cell — clips independently */
45
+ .headerLabel {
46
+ overflow: hidden;
47
+ text-overflow: ellipsis;
48
+ white-space: nowrap;
49
+ }
50
+
51
+ .sortable {
52
+ cursor: pointer;
53
+ }
54
+
55
+ .sortable:hover {
56
+ color: var(--text-primary);
57
+ }
58
+
59
+ .sortIcon {
60
+ font-size: 10px;
61
+ color: var(--text-disabled);
62
+ }
63
+
64
+ .sortActive {
65
+ color: var(--color-info);
66
+ }
67
+
68
+ /* Filter button */
69
+ .filterBtn {
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ width: 16px;
74
+ height: 16px;
75
+ border: none;
76
+ background: none;
77
+ padding: 0;
78
+ color: var(--text-disabled);
79
+ cursor: pointer;
80
+ border-radius: 2px;
81
+ }
82
+
83
+ .filterBtn:hover {
84
+ color: var(--text-secondary);
85
+ }
86
+
87
+ .filterActive {
88
+ color: var(--color-info);
89
+ }
90
+
91
+ /* Filter dropdown (rendered via Portal at body level) */
92
+ .filterDropdown {
93
+ z-index: var(--z-dropdown);
94
+ min-width: 160px;
95
+ max-height: 280px;
96
+ overflow-y: auto;
97
+ background: var(--color-frame);
98
+ border: 1px solid var(--color-border);
99
+ border-radius: var(--radius-button);
100
+ box-shadow: var(--shadow-lg);
101
+ padding: var(--space-xs) 0;
102
+ }
103
+
104
+ .filterSearch {
105
+ padding: var(--space-xs) var(--space-sm);
106
+ border-bottom: 1px solid var(--color-border-subtle);
107
+ margin-bottom: var(--space-xs);
108
+ }
109
+
110
+ .filterSearch input {
111
+ width: 100%;
112
+ padding: 4px 8px;
113
+ font-size: var(--font-size-xs);
114
+ background: var(--color-surface);
115
+ border: 1px solid var(--color-border);
116
+ border-radius: var(--radius-badge);
117
+ color: var(--text-primary);
118
+ outline: none;
119
+ }
120
+
121
+ .filterItem {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 6px;
125
+ padding: 4px var(--space-sm);
126
+ font-size: var(--font-size-xs);
127
+ color: var(--text-secondary);
128
+ cursor: pointer;
129
+ }
130
+
131
+ .filterItem:hover {
132
+ background: var(--color-surface);
133
+ }
134
+
135
+ .filterCheckbox {
136
+ width: 14px;
137
+ height: 14px;
138
+ accent-color: var(--color-info);
139
+ }
140
+
141
+ .filterActions {
142
+ display: flex;
143
+ justify-content: space-between;
144
+ padding: var(--space-xs) var(--space-sm);
145
+ border-top: 1px solid var(--color-border-subtle);
146
+ margin-top: var(--space-xs);
147
+ }
148
+
149
+ /* Body */
150
+ .body {
151
+ flex: 1;
152
+ overflow: auto;
153
+ min-height: 0;
154
+ }
155
+
156
+ /* Row */
157
+ .row {
158
+ display: flex;
159
+ align-items: center;
160
+ border-bottom: 1px solid var(--color-border-subtle);
161
+ transition: background var(--transition-fast);
162
+ }
163
+
164
+ .row:hover {
165
+ background: var(--color-hover);
166
+ }
167
+
168
+ .rowSelected {
169
+ background: var(--color-info-bg);
170
+ }
171
+
172
+ .rowSelected:hover {
173
+ background: var(--color-info-border);
174
+ }
175
+
176
+ .rowClickable {
177
+ cursor: pointer;
178
+ }
179
+
180
+ /* Cell */
181
+ .cell {
182
+ padding: 5px 10px;
183
+ font-size: var(--font-size-xs);
184
+ color: var(--text-secondary);
185
+ overflow: hidden;
186
+ white-space: nowrap;
187
+ text-overflow: ellipsis;
188
+ border-right: 1px solid transparent;
189
+ flex-shrink: 0;
190
+ }
191
+
192
+ .cellFlex {
193
+ composes: cell;
194
+ flex: 1;
195
+ min-width: 0;
196
+ }
197
+
198
+ /* Checkbox column */
199
+ .checkboxCell {
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: center;
203
+ width: 40px;
204
+ flex-shrink: 0;
205
+ padding: 0;
206
+ }
207
+
208
+ .checkbox {
209
+ width: 14px;
210
+ height: 14px;
211
+ accent-color: var(--color-info);
212
+ cursor: pointer;
213
+ }
214
+
215
+ /* Empty state */
216
+ .empty {
217
+ display: flex;
218
+ align-items: center;
219
+ justify-content: center;
220
+ padding: var(--space-4xl);
221
+ color: var(--text-muted);
222
+ font-size: var(--font-size-sm);
223
+ }
224
+
225
+ /* Expanded row */
226
+ .expandedContent {
227
+ padding: var(--space-sm) var(--space-lg);
228
+ background: var(--color-surface);
229
+ border-bottom: 1px solid var(--color-border);
230
+ }
@@ -0,0 +1,416 @@
1
+ import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import styles from './VirtualTable.module.css'
4
+
5
+ /* ---- Types ---- */
6
+ export interface VTColumn<T> {
7
+ key: string
8
+ title: string
9
+ dataIndex?: string
10
+ width?: number
11
+ render?: (value: unknown, record: T, index: number) => React.ReactNode
12
+ sorter?: (a: T, b: T) => number
13
+ ellipsis?: boolean
14
+ filters?: Array<{ text: string; value: string }>
15
+ filterSearch?: boolean
16
+ onFilter?: (value: string, record: T) => boolean
17
+ }
18
+
19
+ export interface VTRowSelection<T> {
20
+ selectedKeys: (string | number)[]
21
+ onChange: (keys: (string | number)[], rows: T[]) => void
22
+ }
23
+
24
+ export interface VirtualTableProps<T> {
25
+ columns: VTColumn<T>[]
26
+ data: T[]
27
+ rowKey: string | ((record: T) => string | number)
28
+ rowHeight?: number
29
+ height?: number
30
+ showHeader?: boolean
31
+ onRow?: (record: T) => { onClick?: () => void; className?: string }
32
+ rowSelection?: VTRowSelection<T>
33
+ expandable?: {
34
+ expandedRowRender: (record: T) => React.ReactNode
35
+ rowExpandable?: (record: T) => boolean
36
+ }
37
+ emptyText?: string
38
+ onFilterDropdownOpenChange?: (open: boolean) => void
39
+ }
40
+
41
+ /* ---- Helpers ---- */
42
+ function getRowKey<T>(record: T, rowKey: string | ((r: T) => string | number)): string | number {
43
+ return typeof rowKey === 'function' ? rowKey(record) : (record as Record<string, unknown>)[rowKey] as string | number
44
+ }
45
+
46
+ function getValue<T>(record: T, dataIndex?: string): unknown {
47
+ if (!dataIndex) return record
48
+ return (record as Record<string, unknown>)[dataIndex]
49
+ }
50
+
51
+ /* ---- Filter Dropdown (Portal-based to avoid overflow clipping) ---- */
52
+ function FilterDropdown({ column, activeFilters, onChange, onClose, filterSearch, anchorRef }: {
53
+ column: { filters: Array<{ text: string; value: string }> }
54
+ activeFilters: Set<string>
55
+ onChange: (filters: Set<string>) => void
56
+ onClose: () => void
57
+ filterSearch?: boolean
58
+ anchorRef: React.RefObject<HTMLButtonElement | null>
59
+ }) {
60
+ const [search, setSearch] = useState('')
61
+ const ref = useRef<HTMLDivElement>(null)
62
+ const [pos, setPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
63
+
64
+ // Calculate position from anchor button
65
+ useEffect(() => {
66
+ if (!anchorRef.current) return
67
+ const rect = anchorRef.current.getBoundingClientRect()
68
+ const dropdownWidth = 180
69
+ // Prefer aligning left edge with button; clamp to viewport
70
+ let left = rect.left
71
+ if (left + dropdownWidth > window.innerWidth) {
72
+ left = Math.max(4, window.innerWidth - dropdownWidth - 4)
73
+ }
74
+ setPos({ top: rect.bottom + 2, left })
75
+ }, [anchorRef])
76
+
77
+ useEffect(() => {
78
+ const handle = (e: MouseEvent) => {
79
+ if (ref.current && !ref.current.contains(e.target as Node) &&
80
+ anchorRef.current && !anchorRef.current.contains(e.target as Node)) {
81
+ onClose()
82
+ }
83
+ }
84
+ document.addEventListener('mousedown', handle)
85
+ return () => document.removeEventListener('mousedown', handle)
86
+ }, [onClose, anchorRef])
87
+
88
+ const filtered = search
89
+ ? column.filters.filter(f => f.text.toLowerCase().includes(search.toLowerCase()))
90
+ : column.filters
91
+
92
+ const toggle = (val: string) => {
93
+ const next = new Set(activeFilters)
94
+ if (next.has(val)) next.delete(val)
95
+ else next.add(val)
96
+ onChange(next)
97
+ }
98
+
99
+ return createPortal(
100
+ <div ref={ref} className={styles.filterDropdown} style={{ position: 'fixed', top: pos.top, left: pos.left }}>
101
+ {filterSearch && (
102
+ <div className={styles.filterSearch}>
103
+ <input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search..." autoFocus />
104
+ </div>
105
+ )}
106
+ {filtered.map(f => (
107
+ <label key={f.value} className={styles.filterItem}>
108
+ <input type="checkbox" className={styles.filterCheckbox} checked={activeFilters.has(f.value)} onChange={() => toggle(f.value)} />
109
+ {f.text}
110
+ </label>
111
+ ))}
112
+ <div className={styles.filterActions}>
113
+ <button style={{ background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 'var(--font-size-xs)' }} onClick={() => onChange(new Set())}>Reset</button>
114
+ <button style={{ background: 'none', border: 'none', color: 'var(--color-info)', cursor: 'pointer', fontSize: 'var(--font-size-xs)' }} onClick={onClose}>OK</button>
115
+ </div>
116
+ </div>,
117
+ document.body
118
+ )
119
+ }
120
+
121
+ /* ---- VirtualTable ---- */
122
+ export function VirtualTable<T>({
123
+ columns,
124
+ data,
125
+ rowKey,
126
+ rowHeight = 34,
127
+ height: heightProp,
128
+ showHeader = true,
129
+ onRow,
130
+ rowSelection,
131
+ expandable,
132
+ emptyText = 'No data',
133
+ onFilterDropdownOpenChange,
134
+ }: VirtualTableProps<T>) {
135
+ const [sortState, setSortState] = useState<{ key: string; dir: 'asc' | 'desc' } | null>(null)
136
+ const [filterState, setFilterState] = useState<Record<string, Set<string>>>({})
137
+ const [openFilter, setOpenFilter] = useState<string | null>(null)
138
+ const [expandedKeys, setExpandedKeys] = useState<Set<string | number>>(new Set())
139
+ const bodyRef = useRef<HTMLDivElement>(null)
140
+ const [autoHeight, setAutoHeight] = useState(heightProp ?? 400)
141
+
142
+ // Auto-detect container height via ResizeObserver when no explicit height given
143
+ useEffect(() => {
144
+ if (heightProp != null) return
145
+ const el = bodyRef.current
146
+ if (!el) return
147
+ const observer = new ResizeObserver((entries) => {
148
+ for (const entry of entries) {
149
+ const h = Math.floor(entry.contentRect.height)
150
+ if (h > 0) setAutoHeight(h)
151
+ }
152
+ })
153
+ observer.observe(el)
154
+ return () => observer.disconnect()
155
+ }, [heightProp])
156
+
157
+ const height = heightProp ?? autoHeight
158
+
159
+ const handleFilterOpen = useCallback((key: string | null) => {
160
+ const wasOpen = openFilter !== null
161
+ const isOpen = key !== null
162
+ setOpenFilter(key)
163
+ if (wasOpen !== isOpen) onFilterDropdownOpenChange?.(isOpen)
164
+ }, [openFilter, onFilterDropdownOpenChange])
165
+
166
+ // Apply filters
167
+ const filteredData = useMemo(() => {
168
+ let result = data
169
+ for (const col of columns) {
170
+ const active = filterState[col.key]
171
+ if (active && active.size > 0 && col.onFilter) {
172
+ result = result.filter(r => {
173
+ for (const val of active) {
174
+ if (col.onFilter!(val, r)) return true
175
+ }
176
+ return false
177
+ })
178
+ }
179
+ }
180
+ return result
181
+ }, [data, columns, filterState])
182
+
183
+ // Apply sort
184
+ const sortedData = useMemo(() => {
185
+ if (!sortState) return filteredData
186
+ const col = columns.find(c => c.key === sortState.key)
187
+ if (!col?.sorter) return filteredData
188
+ const sorted = [...filteredData].sort(col.sorter)
189
+ return sortState.dir === 'desc' ? sorted.reverse() : sorted
190
+ }, [filteredData, sortState, columns])
191
+
192
+ const toggleSort = (key: string) => {
193
+ setSortState(prev => {
194
+ if (prev?.key !== key) return { key, dir: 'asc' }
195
+ if (prev.dir === 'asc') return { key, dir: 'desc' }
196
+ return null
197
+ })
198
+ }
199
+
200
+ const toggleExpand = useCallback((key: string | number) => {
201
+ setExpandedKeys(prev => {
202
+ const next = new Set(prev)
203
+ if (next.has(key)) next.delete(key)
204
+ else next.add(key)
205
+ return next
206
+ })
207
+ }, [])
208
+
209
+ const allKeys = useMemo(() => sortedData.map(r => getRowKey(r, rowKey)), [sortedData, rowKey])
210
+ const allSelected = rowSelection && allKeys.length > 0 && allKeys.every(k => rowSelection.selectedKeys.includes(k))
211
+
212
+ const toggleSelectAll = () => {
213
+ if (!rowSelection) return
214
+ if (allSelected) rowSelection.onChange([], [])
215
+ else rowSelection.onChange(allKeys, sortedData)
216
+ }
217
+
218
+ // Track last clicked row index for shift+click range selection
219
+ const lastClickedIndexRef = useRef<number | null>(null)
220
+
221
+ const toggleSelectRow = (record: T, event?: React.MouseEvent) => {
222
+ if (!rowSelection) return
223
+ const key = getRowKey(record, rowKey)
224
+ const currentIndex = sortedData.findIndex(r => getRowKey(r, rowKey) === key)
225
+
226
+ // Shift+click: range selection
227
+ if (event?.shiftKey && lastClickedIndexRef.current !== null && currentIndex >= 0) {
228
+ const start = Math.min(lastClickedIndexRef.current, currentIndex)
229
+ const end = Math.max(lastClickedIndexRef.current, currentIndex)
230
+ const rangeKeys = sortedData.slice(start, end + 1).map(r => getRowKey(r, rowKey))
231
+ // Merge with existing selection
232
+ const mergedKeys = new Set([...rowSelection.selectedKeys, ...rangeKeys])
233
+ const nextKeys = Array.from(mergedKeys)
234
+ const nextRows = sortedData.filter(r => nextKeys.includes(getRowKey(r, rowKey)))
235
+ rowSelection.onChange(nextKeys, nextRows)
236
+ lastClickedIndexRef.current = currentIndex
237
+ return
238
+ }
239
+
240
+ lastClickedIndexRef.current = currentIndex
241
+ const idx = rowSelection.selectedKeys.indexOf(key)
242
+ let nextKeys: (string | number)[]
243
+ if (idx >= 0) nextKeys = rowSelection.selectedKeys.filter(k => k !== key)
244
+ else nextKeys = [...rowSelection.selectedKeys, key]
245
+ const nextRows = sortedData.filter(r => nextKeys.includes(getRowKey(r, rowKey)))
246
+ rowSelection.onChange(nextKeys, nextRows)
247
+ }
248
+
249
+ const hasExpand = !!expandable
250
+
251
+ // Refs for filter button anchors
252
+ const filterBtnRefs = useRef<Record<string, HTMLButtonElement | null>>({})
253
+
254
+ // Render header
255
+ const renderHeader = () => (
256
+ <div className={styles.headerRow}>
257
+ {rowSelection && (
258
+ <div className={styles.checkboxCell}>
259
+ <input type="checkbox" className={styles.checkbox} checked={allSelected ?? false} onChange={toggleSelectAll} />
260
+ </div>
261
+ )}
262
+ {hasExpand && <div style={{ width: 32, flexShrink: 0 }} />}
263
+ {columns.map(col => {
264
+ const hasSorter = !!col.sorter
265
+ const hasFilter = col.filters && col.filters.length > 0
266
+ const isFlexCol = !col.width
267
+ const cls = isFlexCol ? styles.headerCellFlex : styles.headerCell
268
+ const sortCls = hasSorter ? styles.sortable : ''
269
+ const activeFilter = filterState[col.key]
270
+ const isFilterActive = activeFilter && activeFilter.size > 0
271
+
272
+ return (
273
+ <div key={col.key} className={`${cls} ${sortCls}`} style={col.width ? { width: col.width } : undefined} onClick={hasSorter ? () => toggleSort(col.key) : undefined}>
274
+ <span className={styles.headerLabel}>{col.title}</span>
275
+ {hasSorter && (
276
+ <span className={`${styles.sortIcon} ${sortState?.key === col.key ? styles.sortActive : ''}`}>
277
+ {sortState?.key === col.key ? (sortState.dir === 'asc' ? '\u25B2' : '\u25BC') : '\u25B4'}
278
+ </span>
279
+ )}
280
+ {hasFilter && (
281
+ <>
282
+ <button
283
+ ref={el => { filterBtnRefs.current[col.key] = el }}
284
+ className={`${styles.filterBtn} ${isFilterActive ? styles.filterActive : ''}`}
285
+ onClick={(e) => { e.stopPropagation(); handleFilterOpen(openFilter === col.key ? null : col.key) }}
286
+ >
287
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M3 4h18l-7 8v6l-4 2v-8z" /></svg>
288
+ </button>
289
+ {openFilter === col.key && (
290
+ <FilterDropdown
291
+ column={col as { filters: Array<{ text: string; value: string }> }}
292
+ activeFilters={activeFilter || new Set()}
293
+ filterSearch={col.filterSearch}
294
+ onChange={(filters) => setFilterState(prev => ({ ...prev, [col.key]: filters }))}
295
+ onClose={() => handleFilterOpen(null)}
296
+ anchorRef={{ current: filterBtnRefs.current[col.key] ?? null }}
297
+ />
298
+ )}
299
+ </>
300
+ )}
301
+ </div>
302
+ )
303
+ })}
304
+ </div>
305
+ )
306
+
307
+ // Render a single row (used by both expandable and virtual modes)
308
+ const renderRow = (record: T, index: number, rowStyle?: React.CSSProperties) => {
309
+ const key = getRowKey(record, rowKey)
310
+ const rowProps = onRow?.(record)
311
+ const isSelected = rowSelection?.selectedKeys.includes(key)
312
+ const isExpanded = hasExpand && expandedKeys.has(key)
313
+ const canExpand = hasExpand && (expandable?.rowExpandable ? expandable.rowExpandable(record) : true)
314
+
315
+ return (
316
+ <React.Fragment key={key}>
317
+ <div
318
+ style={rowStyle}
319
+ className={`${styles.row} ${isSelected ? styles.rowSelected : ''} ${rowProps?.className ?? ''} ${rowProps?.onClick ? styles.rowClickable : ''}`}
320
+ onClick={rowProps?.onClick}
321
+ >
322
+ {rowSelection && (
323
+ <div className={styles.checkboxCell}>
324
+ <input type="checkbox" className={styles.checkbox} checked={isSelected ?? false} onClick={(e) => { e.stopPropagation(); toggleSelectRow(record, e as unknown as React.MouseEvent) }} readOnly />
325
+ </div>
326
+ )}
327
+ {hasExpand && (
328
+ <div
329
+ style={{ width: 32, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: canExpand ? 'pointer' : 'default', color: 'var(--text-muted)' }}
330
+ onClick={(e) => { e.stopPropagation(); if (canExpand) toggleExpand(key) }}
331
+ >
332
+ {canExpand && (
333
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ transform: isExpanded ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}>
334
+ <polyline points="9 18 15 12 9 6" />
335
+ </svg>
336
+ )}
337
+ </div>
338
+ )}
339
+ {columns.map(col => {
340
+ const val = getValue(record, col.dataIndex)
341
+ const isFlexCol = !col.width
342
+ return (
343
+ <div key={col.key} className={isFlexCol ? styles.cellFlex : styles.cell} style={col.width ? { width: col.width } : undefined}>
344
+ {col.render ? col.render(val, record, index) : String(val ?? '')}
345
+ </div>
346
+ )
347
+ })}
348
+ </div>
349
+ {isExpanded && (
350
+ <div className={styles.expandedContent}>
351
+ {expandable!.expandedRowRender(record)}
352
+ </div>
353
+ )}
354
+ </React.Fragment>
355
+ )
356
+ }
357
+
358
+ // If using expandable rows, use native scroll (variable height rows)
359
+ if (hasExpand) {
360
+ return (
361
+ <div className={styles.wrapper}>
362
+ {showHeader && renderHeader()}
363
+ <div className={styles.body} style={{ maxHeight: height }}>
364
+ {sortedData.length === 0 ? (
365
+ <div className={styles.empty}>{emptyText}</div>
366
+ ) : (
367
+ sortedData.map((record, idx) => renderRow(record, idx))
368
+ )}
369
+ </div>
370
+ </div>
371
+ )
372
+ }
373
+
374
+ // Virtual scroll mode (no expandable rows) — native implementation
375
+ const [scrollTop, setScrollTop] = useState(0)
376
+ const scrollContainerRef = useRef<HTMLDivElement>(null)
377
+
378
+ const totalHeight = sortedData.length * rowHeight
379
+ const visibleCount = Math.ceil(height / rowHeight) + 2 // overscan by 2
380
+ const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - 1)
381
+ const endIndex = Math.min(sortedData.length, startIndex + visibleCount)
382
+
383
+ const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
384
+ setScrollTop(e.currentTarget.scrollTop)
385
+ }, [])
386
+
387
+ return (
388
+ <div className={styles.wrapper}>
389
+ {showHeader && renderHeader()}
390
+ <div ref={bodyRef} style={{ flex: 1, minHeight: 0 }}>
391
+ {sortedData.length === 0 ? (
392
+ <div className={styles.empty}>{emptyText}</div>
393
+ ) : (
394
+ <div
395
+ ref={scrollContainerRef}
396
+ onScroll={handleScroll}
397
+ style={{ height, width: '100%', overflow: 'auto' }}
398
+ >
399
+ <div style={{ height: totalHeight, position: 'relative' }}>
400
+ {sortedData.slice(startIndex, endIndex).map((record, i) => {
401
+ const actualIndex = startIndex + i
402
+ return renderRow(record, actualIndex, {
403
+ position: 'absolute',
404
+ top: actualIndex * rowHeight,
405
+ left: 0,
406
+ right: 0,
407
+ height: rowHeight,
408
+ })
409
+ })}
410
+ </div>
411
+ </div>
412
+ )}
413
+ </div>
414
+ </div>
415
+ )
416
+ }
@@ -0,0 +1,55 @@
1
+ // UI Component Library — Barrel Export
2
+
3
+ export { Button } from './Button'
4
+ export type { ButtonProps, ButtonVariant, ButtonSize } from './Button'
5
+
6
+ export { Input, TextArea, PasswordInput } from './Input'
7
+ export type { InputProps, TextAreaProps, PasswordInputProps } from './Input'
8
+
9
+ export { Select } from './Select'
10
+ export type { SelectProps, SelectOption } from './Select'
11
+
12
+ export { InputNumber } from './InputNumber'
13
+ export type { InputNumberProps } from './InputNumber'
14
+
15
+ export { Tag } from './Tag'
16
+ export type { TagProps, TagColor } from './Tag'
17
+
18
+ export { Badge } from './Badge'
19
+ export type { BadgeProps } from './Badge'
20
+
21
+ export { Switch } from './Switch'
22
+ export type { SwitchProps } from './Switch'
23
+
24
+ export { Modal } from './Modal'
25
+ export type { ModalProps } from './Modal'
26
+
27
+ export { Tabs } from './Tabs'
28
+ export type { TabsProps, TabItem } from './Tabs'
29
+
30
+ export { ToastProvider, useToast } from './Toast'
31
+
32
+ export { Popconfirm } from './Popconfirm'
33
+ export type { PopconfirmProps } from './Popconfirm'
34
+
35
+ export { Tooltip } from './Tooltip'
36
+ export type { TooltipProps } from './Tooltip'
37
+
38
+ export { Spinner } from './Spinner'
39
+ export type { SpinnerProps } from './Spinner'
40
+
41
+ export { Progress } from './Progress'
42
+ export type { ProgressProps } from './Progress'
43
+
44
+ export { Empty } from './Empty'
45
+ export type { EmptyProps } from './Empty'
46
+
47
+ // Icons
48
+ export * from './Icons'
49
+
50
+ // Phase 3 components
51
+ export { CopyableBlock } from './CopyableBlock'
52
+ export { Collapse } from './Collapse'
53
+ export { Timeline } from './Timeline'
54
+ export { VirtualTable } from './VirtualTable'
55
+ export type { VTColumn, VTRowSelection, VirtualTableProps } from './VirtualTable'