@elabs-ai/components-ui 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/index.d.ts +4497 -0
- package/dist/index.js +15192 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/cn.d.ts +10 -0
- package/dist/lib/cn.js +20 -0
- package/dist/lib/cn.js.map +1 -0
- package/package.json +106 -0
- package/src/blocks/sidebar-02/app-sidebar.tsx +160 -0
- package/src/blocks/sidebar-02/logo.tsx +18 -0
- package/src/blocks/sidebar-02/nav-main.tsx +13 -0
- package/src/blocks/sidebar-02/nav-notifications.tsx +7 -0
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +23 -0
- package/src/blocks/sidebar-02/team-switcher.tsx +7 -0
- package/src/blocks/sidebar-04/app-sidebar.tsx +236 -0
- package/src/blocks/sidebar-04/mail-context.tsx +29 -0
- package/src/blocks/sidebar-04/nav-user.tsx +6 -0
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +150 -0
- package/src/blocks/sidebar-05/app-sidebar.tsx +375 -0
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +18 -0
- package/src/blocks/sidebar-05/team-switcher.tsx +6 -0
- package/src/blocks-comparison-table.stories.tsx +165 -0
- package/src/components/accordion/accordion.stories.tsx +71 -0
- package/src/components/accordion/accordion.test.tsx +18 -0
- package/src/components/accordion/accordion.tsx +49 -0
- package/src/components/accordion/index.ts +1 -0
- package/src/components/advanced-group/advanced-group.stories.tsx +143 -0
- package/src/components/advanced-group/advanced-group.test.tsx +81 -0
- package/src/components/advanced-group/advanced-group.tsx +111 -0
- package/src/components/advanced-group/index.ts +2 -0
- package/src/components/alert/alert.stories.tsx +156 -0
- package/src/components/alert/alert.test.tsx +57 -0
- package/src/components/alert/alert.tsx +91 -0
- package/src/components/alert/index.ts +8 -0
- package/src/components/alert-dialog/alert-dialog.stories.tsx +73 -0
- package/src/components/alert-dialog/alert-dialog.tsx +104 -0
- package/src/components/alert-dialog/index.ts +12 -0
- package/src/components/app-shell/app-shell.stories.tsx +61 -0
- package/src/components/app-shell/app-shell.tsx +31 -0
- package/src/components/app-shell/index.ts +1 -0
- package/src/components/app-sidebar/app-sidebar.stories.tsx +121 -0
- package/src/components/app-sidebar/app-sidebar.test.tsx +55 -0
- package/src/components/app-sidebar/app-sidebar.tsx +53 -0
- package/src/components/app-sidebar/index.ts +1 -0
- package/src/components/aspect-ratio/aspect-ratio.stories.tsx +46 -0
- package/src/components/aspect-ratio/aspect-ratio.tsx +3 -0
- package/src/components/aspect-ratio/index.ts +1 -0
- package/src/components/attribution-panel/attribution-panel.stories.tsx +83 -0
- package/src/components/attribution-panel/attribution-panel.test.tsx +123 -0
- package/src/components/attribution-panel/attribution-panel.tsx +191 -0
- package/src/components/attribution-panel/attribution-types.ts +44 -0
- package/src/components/attribution-panel/attributions.generated.ts +1422 -0
- package/src/components/attribution-panel/index.ts +3 -0
- package/src/components/avatar/avatar.stories.tsx +45 -0
- package/src/components/avatar/avatar.test.tsx +13 -0
- package/src/components/avatar/avatar.tsx +46 -0
- package/src/components/avatar/index.ts +1 -0
- package/src/components/badge/badge.stories.tsx +44 -0
- package/src/components/badge/badge.test.tsx +10 -0
- package/src/components/badge/badge.tsx +28 -0
- package/src/components/badge/index.ts +1 -0
- package/src/components/bento-grid/bento-grid.stories.tsx +165 -0
- package/src/components/bento-grid/bento-grid.test.tsx +119 -0
- package/src/components/bento-grid/bento-grid.tsx +339 -0
- package/src/components/bento-grid/index.ts +9 -0
- package/src/components/bounded-number/bounded-number.stories.tsx +118 -0
- package/src/components/bounded-number/bounded-number.test.tsx +73 -0
- package/src/components/bounded-number/bounded-number.tsx +110 -0
- package/src/components/bounded-number/index.ts +1 -0
- package/src/components/breadcrumb/breadcrumb.stories.tsx +47 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +17 -0
- package/src/components/breadcrumb/breadcrumb.tsx +86 -0
- package/src/components/breadcrumb/index.ts +9 -0
- package/src/components/button/button.stories.tsx +70 -0
- package/src/components/button/button.test.tsx +50 -0
- package/src/components/button/button.tsx +63 -0
- package/src/components/button/index.ts +1 -0
- package/src/components/button-group/button-group.stories.tsx +45 -0
- package/src/components/button-group/button-group.tsx +70 -0
- package/src/components/button-group/index.ts +6 -0
- package/src/components/calendar/calendar.stories.tsx +95 -0
- package/src/components/calendar/calendar.test.tsx +140 -0
- package/src/components/calendar/calendar.tsx +70 -0
- package/src/components/calendar/index.ts +1 -0
- package/src/components/card/card.stories.tsx +438 -0
- package/src/components/card/card.test.tsx +153 -0
- package/src/components/card/card.tsx +332 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/carousel/carousel.stories.tsx +102 -0
- package/src/components/carousel/carousel.tsx +225 -0
- package/src/components/carousel/index.ts +8 -0
- package/src/components/change-review/change-review.stories.tsx +288 -0
- package/src/components/change-review/change-review.test.tsx +261 -0
- package/src/components/change-review/change-review.tsx +637 -0
- package/src/components/change-review/index.ts +23 -0
- package/src/components/checkbox/checkbox.stories.tsx +119 -0
- package/src/components/checkbox/checkbox.test.tsx +40 -0
- package/src/components/checkbox/checkbox.tsx +34 -0
- package/src/components/checkbox/index.ts +1 -0
- package/src/components/collapsible/collapsible.stories.tsx +62 -0
- package/src/components/collapsible/collapsible.tsx +22 -0
- package/src/components/collapsible/index.ts +1 -0
- package/src/components/collapsible-panel/index.ts +8 -0
- package/src/components/collapsible-panel/use-collapsible-panel.test.tsx +93 -0
- package/src/components/collapsible-panel/use-collapsible-panel.ts +159 -0
- package/src/components/color-picker/color-picker.stories.tsx +93 -0
- package/src/components/color-picker/color-picker.test.tsx +79 -0
- package/src/components/color-picker/color-picker.tsx +341 -0
- package/src/components/color-picker/index.ts +1 -0
- package/src/components/combobox/combobox.stories.tsx +154 -0
- package/src/components/combobox/combobox.test.tsx +246 -0
- package/src/components/combobox/combobox.tsx +210 -0
- package/src/components/combobox/index.ts +1 -0
- package/src/components/command/command.stories.tsx +371 -0
- package/src/components/command/command.test.tsx +280 -0
- package/src/components/command/command.tsx +301 -0
- package/src/components/command/index.ts +13 -0
- package/src/components/confirm-dialog/confirm-dialog.stories.tsx +183 -0
- package/src/components/confirm-dialog/confirm-dialog.test.tsx +147 -0
- package/src/components/confirm-dialog/confirm-dialog.tsx +146 -0
- package/src/components/confirm-dialog/index.ts +2 -0
- package/src/components/context-menu/context-menu.stories.tsx +70 -0
- package/src/components/context-menu/context-menu.tsx +172 -0
- package/src/components/context-menu/index.ts +16 -0
- package/src/components/copyable-value/copyable-value.stories.tsx +80 -0
- package/src/components/copyable-value/copyable-value.test.tsx +128 -0
- package/src/components/copyable-value/copyable-value.tsx +101 -0
- package/src/components/copyable-value/index.ts +1 -0
- package/src/components/date-picker/date-picker.stories.tsx +42 -0
- package/src/components/date-picker/date-picker.tsx +49 -0
- package/src/components/date-picker/index.ts +1 -0
- package/src/components/date-range-picker/date-range-picker.stories.tsx +151 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +62 -0
- package/src/components/date-range-picker/date-range-picker.tsx +126 -0
- package/src/components/date-range-picker/index.ts +6 -0
- package/src/components/descriptions/descriptions.stories.tsx +43 -0
- package/src/components/descriptions/descriptions.test.tsx +41 -0
- package/src/components/descriptions/descriptions.tsx +95 -0
- package/src/components/descriptions/index.ts +6 -0
- package/src/components/dialog/dialog.stories.tsx +728 -0
- package/src/components/dialog/dialog.test.tsx +285 -0
- package/src/components/dialog/dialog.tsx +340 -0
- package/src/components/dialog/index.ts +18 -0
- package/src/components/dialog/use-dialog-dismiss-guard.test.tsx +80 -0
- package/src/components/dialog/use-dialog-dismiss-guard.ts +109 -0
- package/src/components/drawer/drawer.stories.tsx +76 -0
- package/src/components/drawer/drawer.tsx +85 -0
- package/src/components/drawer/index.ts +11 -0
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +176 -0
- package/src/components/dropdown-menu/dropdown-menu.test.tsx +109 -0
- package/src/components/dropdown-menu/dropdown-menu.tsx +179 -0
- package/src/components/dropdown-menu/index.ts +17 -0
- package/src/components/empty-state/empty-state.stories.tsx +54 -0
- package/src/components/empty-state/empty-state.test.tsx +11 -0
- package/src/components/empty-state/empty-state.tsx +30 -0
- package/src/components/empty-state/index.ts +1 -0
- package/src/components/error-state/error-state.stories.tsx +51 -0
- package/src/components/error-state/error-state.tsx +27 -0
- package/src/components/error-state/index.ts +1 -0
- package/src/components/expand-dialog/expand-dialog.stories.tsx +246 -0
- package/src/components/expand-dialog/expand-dialog.test.tsx +185 -0
- package/src/components/expand-dialog/expand-dialog.tsx +387 -0
- package/src/components/expand-dialog/index.ts +11 -0
- package/src/components/field-row/field-row.stories.tsx +114 -0
- package/src/components/field-row/field-row.test.tsx +188 -0
- package/src/components/field-row/field-row.tsx +115 -0
- package/src/components/field-row/index.ts +1 -0
- package/src/components/file-upload/file-upload.stories.tsx +183 -0
- package/src/components/file-upload/file-upload.test.tsx +169 -0
- package/src/components/file-upload/file-upload.tsx +459 -0
- package/src/components/file-upload/index.ts +13 -0
- package/src/components/form/form.stories.tsx +71 -0
- package/src/components/form/form.tsx +141 -0
- package/src/components/form/index.ts +10 -0
- package/src/components/hover-card/hover-card.stories.tsx +57 -0
- package/src/components/hover-card/hover-card.tsx +25 -0
- package/src/components/hover-card/index.ts +1 -0
- package/src/components/icon-button/icon-button.stories.tsx +108 -0
- package/src/components/icon-button/icon-button.test.tsx +93 -0
- package/src/components/icon-button/icon-button.tsx +139 -0
- package/src/components/icon-button/index.ts +1 -0
- package/src/components/input/index.ts +1 -0
- package/src/components/input/input.stories.tsx +44 -0
- package/src/components/input/input.test.tsx +19 -0
- package/src/components/input/input.tsx +26 -0
- package/src/components/input-group/index.ts +11 -0
- package/src/components/input-group/input-group.stories.tsx +87 -0
- package/src/components/input-group/input-group.test.tsx +48 -0
- package/src/components/input-group/input-group.tsx +170 -0
- package/src/components/input-otp/index.ts +1 -0
- package/src/components/input-otp/input-otp.stories.tsx +66 -0
- package/src/components/input-otp/input-otp.tsx +68 -0
- package/src/components/kbd/index.ts +1 -0
- package/src/components/kbd/kbd.stories.tsx +45 -0
- package/src/components/kbd/kbd.tsx +20 -0
- package/src/components/key-value-editor/index.ts +1 -0
- package/src/components/key-value-editor/key-value-editor.stories.tsx +130 -0
- package/src/components/key-value-editor/key-value-editor.test.tsx +231 -0
- package/src/components/key-value-editor/key-value-editor.tsx +263 -0
- package/src/components/label/index.ts +1 -0
- package/src/components/label/label.stories.tsx +35 -0
- package/src/components/label/label.tsx +19 -0
- package/src/components/link-preview/index.ts +2 -0
- package/src/components/link-preview/link-preview.stories.tsx +178 -0
- package/src/components/link-preview/link-preview.test.tsx +123 -0
- package/src/components/link-preview/link-preview.tsx +194 -0
- package/src/components/list-editor/index.ts +1 -0
- package/src/components/list-editor/list-editor.stories.tsx +119 -0
- package/src/components/list-editor/list-editor.test.tsx +96 -0
- package/src/components/list-editor/list-editor.tsx +192 -0
- package/src/components/loading-state/index.ts +1 -0
- package/src/components/loading-state/loading-state.stories.tsx +38 -0
- package/src/components/loading-state/loading-state.tsx +26 -0
- package/src/components/locale-provider/index.ts +8 -0
- package/src/components/locale-provider/locale-provider.stories.tsx +83 -0
- package/src/components/locale-provider/locale-provider.test.tsx +238 -0
- package/src/components/locale-provider/locale-provider.tsx +187 -0
- package/src/components/locale-provider/messages.ts +300 -0
- package/src/components/match-highlight/index.ts +7 -0
- package/src/components/match-highlight/match-highlight.stories.tsx +146 -0
- package/src/components/match-highlight/match-highlight.test.tsx +202 -0
- package/src/components/match-highlight/match-highlight.tsx +145 -0
- package/src/components/mention-input/index.ts +28 -0
- package/src/components/mention-input/mention-input-mirror.stories.tsx +171 -0
- package/src/components/mention-input/mention-input.stories.tsx +463 -0
- package/src/components/mention-input/mention-input.test.tsx +617 -0
- package/src/components/mention-input/mention-input.tsx +1102 -0
- package/src/components/mention-input/mention-value.test.ts +253 -0
- package/src/components/mention-input/mention-value.ts +193 -0
- package/src/components/menubar/index.ts +17 -0
- package/src/components/menubar/menubar.stories.tsx +67 -0
- package/src/components/menubar/menubar.tsx +198 -0
- package/src/components/metric-card/index.ts +1 -0
- package/src/components/metric-card/metric-card.stories.tsx +224 -0
- package/src/components/metric-card/metric-card.test.tsx +198 -0
- package/src/components/metric-card/metric-card.tsx +229 -0
- package/src/components/model-picker/index.ts +11 -0
- package/src/components/model-picker/model-picker-state.ts +85 -0
- package/src/components/model-picker/model-picker.stories.tsx +128 -0
- package/src/components/model-picker/model-picker.test.tsx +241 -0
- package/src/components/model-picker/model-picker.tsx +270 -0
- package/src/components/nav-main/index.ts +1 -0
- package/src/components/nav-main/nav-main.tsx +147 -0
- package/src/components/nav-notifications/index.ts +5 -0
- package/src/components/nav-notifications/nav-notifications.test.tsx +15 -0
- package/src/components/nav-notifications/nav-notifications.tsx +70 -0
- package/src/components/nav-user/index.ts +1 -0
- package/src/components/nav-user/nav-user.test.tsx +29 -0
- package/src/components/nav-user/nav-user.tsx +114 -0
- package/src/components/navigation-menu/index.ts +10 -0
- package/src/components/navigation-menu/navigation-menu.stories.tsx +92 -0
- package/src/components/navigation-menu/navigation-menu.tsx +99 -0
- package/src/components/number-input/index.ts +1 -0
- package/src/components/number-input/number-input.stories.tsx +120 -0
- package/src/components/number-input/number-input.test.tsx +96 -0
- package/src/components/number-input/number-input.tsx +219 -0
- package/src/components/page-shell/index.ts +1 -0
- package/src/components/page-shell/page-shell.stories.tsx +105 -0
- package/src/components/page-shell/page-shell.test.tsx +57 -0
- package/src/components/page-shell/page-shell.tsx +62 -0
- package/src/components/pagination/index.ts +9 -0
- package/src/components/pagination/pagination.stories.tsx +57 -0
- package/src/components/pagination/pagination.tsx +77 -0
- package/src/components/popover/index.ts +1 -0
- package/src/components/popover/popover.stories.tsx +28 -0
- package/src/components/popover/popover.test.tsx +16 -0
- package/src/components/popover/popover.tsx +29 -0
- package/src/components/progress/index.ts +1 -0
- package/src/components/progress/progress.stories.tsx +80 -0
- package/src/components/progress/progress.test.tsx +35 -0
- package/src/components/progress/progress.tsx +50 -0
- package/src/components/radio-group/index.ts +1 -0
- package/src/components/radio-group/radio-group.stories.tsx +75 -0
- package/src/components/radio-group/radio-group.test.tsx +16 -0
- package/src/components/radio-group/radio-group.tsx +33 -0
- package/src/components/rating/index.ts +1 -0
- package/src/components/rating/rating.stories.tsx +111 -0
- package/src/components/rating/rating.test.tsx +41 -0
- package/src/components/rating/rating.tsx +226 -0
- package/src/components/resizable/index.ts +1 -0
- package/src/components/resizable/resizable.stories.tsx +25 -0
- package/src/components/resizable/resizable.tsx +40 -0
- package/src/components/reveal/index.ts +7 -0
- package/src/components/reveal/reveal.stories.tsx +90 -0
- package/src/components/reveal/reveal.test.tsx +43 -0
- package/src/components/reveal/reveal.tsx +95 -0
- package/src/components/revision-timeline/index.ts +10 -0
- package/src/components/revision-timeline/revision-timeline.stories.tsx +785 -0
- package/src/components/revision-timeline/revision-timeline.test.tsx +509 -0
- package/src/components/revision-timeline/revision-timeline.tsx +1107 -0
- package/src/components/scroll-area/index.ts +1 -0
- package/src/components/scroll-area/scroll-area.stories.tsx +45 -0
- package/src/components/scroll-area/scroll-area.tsx +43 -0
- package/src/components/section-header/index.ts +1 -0
- package/src/components/section-header/section-header.stories.tsx +20 -0
- package/src/components/section-header/section-header.tsx +36 -0
- package/src/components/segmented-field/index.ts +5 -0
- package/src/components/segmented-field/segmented-field.stories.tsx +121 -0
- package/src/components/segmented-field/segmented-field.test.tsx +163 -0
- package/src/components/segmented-field/segmented-field.tsx +235 -0
- package/src/components/select/index.ts +10 -0
- package/src/components/select/select.stories.tsx +106 -0
- package/src/components/select/select.test.tsx +98 -0
- package/src/components/select/select.tsx +230 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +87 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/sheet/index.ts +12 -0
- package/src/components/sheet/sheet.stories.tsx +65 -0
- package/src/components/sheet/sheet.tsx +109 -0
- package/src/components/sidebar/index.ts +27 -0
- package/src/components/sidebar/sidebar.stories.tsx +69 -0
- package/src/components/sidebar/sidebar.test.tsx +43 -0
- package/src/components/sidebar/sidebar.tsx +688 -0
- package/src/components/skeleton/index.ts +1 -0
- package/src/components/skeleton/skeleton.stories.tsx +28 -0
- package/src/components/skeleton/skeleton.tsx +15 -0
- package/src/components/slider/index.ts +1 -0
- package/src/components/slider/slider.stories.tsx +211 -0
- package/src/components/slider/slider.test.tsx +168 -0
- package/src/components/slider/slider.tsx +136 -0
- package/src/components/slider-number/index.ts +1 -0
- package/src/components/slider-number/slider-number.stories.tsx +123 -0
- package/src/components/slider-number/slider-number.test.tsx +165 -0
- package/src/components/slider-number/slider-number.tsx +191 -0
- package/src/components/sonner/index.ts +1 -0
- package/src/components/sonner/sonner.stories.tsx +124 -0
- package/src/components/sonner/sonner.test.tsx +76 -0
- package/src/components/sonner/sonner.tsx +101 -0
- package/src/components/spinner/index.ts +1 -0
- package/src/components/spinner/spinner.stories.tsx +27 -0
- package/src/components/spinner/spinner.tsx +19 -0
- package/src/components/split-panel/index.ts +1 -0
- package/src/components/split-panel/split-panel.stories.tsx +60 -0
- package/src/components/split-panel/split-panel.test.tsx +38 -0
- package/src/components/split-panel/split-panel.tsx +95 -0
- package/src/components/state-panel/index.ts +1 -0
- package/src/components/state-panel/state-panel.stories.tsx +80 -0
- package/src/components/state-panel/state-panel.test.tsx +64 -0
- package/src/components/state-panel/state-panel.tsx +167 -0
- package/src/components/status-badge/index.ts +18 -0
- package/src/components/status-badge/status-badge.stories.tsx +132 -0
- package/src/components/status-badge/status-badge.test.tsx +189 -0
- package/src/components/status-badge/status-badge.tsx +330 -0
- package/src/components/switch/index.ts +1 -0
- package/src/components/switch/switch.stories.tsx +58 -0
- package/src/components/switch/switch.test.tsx +12 -0
- package/src/components/switch/switch.tsx +29 -0
- package/src/components/table/index.ts +10 -0
- package/src/components/table/table.stories.tsx +53 -0
- package/src/components/table/table.test.tsx +17 -0
- package/src/components/table/table.tsx +106 -0
- package/src/components/tabs/index.ts +1 -0
- package/src/components/tabs/tabs.stories.tsx +267 -0
- package/src/components/tabs/tabs.test.tsx +94 -0
- package/src/components/tabs/tabs.tsx +170 -0
- package/src/components/tag-input/index.ts +1 -0
- package/src/components/tag-input/tag-input.stories.tsx +120 -0
- package/src/components/tag-input/tag-input.test.tsx +97 -0
- package/src/components/tag-input/tag-input.tsx +253 -0
- package/src/components/team-switcher/index.ts +1 -0
- package/src/components/team-switcher/team-switcher.test.tsx +39 -0
- package/src/components/team-switcher/team-switcher.tsx +100 -0
- package/src/components/textarea/index.ts +1 -0
- package/src/components/textarea/textarea.stories.tsx +42 -0
- package/src/components/textarea/textarea.tsx +24 -0
- package/src/components/theme-switcher/index.ts +2 -0
- package/src/components/theme-switcher/theme-switcher.stories.tsx +146 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +253 -0
- package/src/components/theme-switcher/theme-switcher.tsx +280 -0
- package/src/components/theme-switcher/use-theme-transition.ts +62 -0
- package/src/components/timeline/index.ts +13 -0
- package/src/components/timeline/timeline.stories.tsx +99 -0
- package/src/components/timeline/timeline.test.tsx +196 -0
- package/src/components/timeline/timeline.tsx +188 -0
- package/src/components/toggle/index.ts +1 -0
- package/src/components/toggle/toggle.stories.tsx +116 -0
- package/src/components/toggle/toggle.test.tsx +119 -0
- package/src/components/toggle/toggle.tsx +64 -0
- package/src/components/toggle-group/index.ts +1 -0
- package/src/components/toggle-group/toggle-group.stories.tsx +110 -0
- package/src/components/toggle-group/toggle-group.tsx +59 -0
- package/src/components/toolbar/index.ts +14 -0
- package/src/components/toolbar/toolbar.stories.tsx +126 -0
- package/src/components/toolbar/toolbar.test.tsx +73 -0
- package/src/components/toolbar/toolbar.tsx +177 -0
- package/src/components/tooltip/index.ts +1 -0
- package/src/components/tooltip/tooltip.stories.tsx +67 -0
- package/src/components/tooltip/tooltip.tsx +27 -0
- package/src/components/top-nav/index.ts +1 -0
- package/src/components/top-nav/top-nav.stories.tsx +18 -0
- package/src/components/top-nav/top-nav.tsx +34 -0
- package/src/components/transfer/index.ts +2 -0
- package/src/components/transfer/transfer.stories.tsx +162 -0
- package/src/components/transfer/transfer.test.tsx +177 -0
- package/src/components/transfer/transfer.tsx +481 -0
- package/src/components/tree/index.ts +6 -0
- package/src/components/tree/tree.stories.tsx +618 -0
- package/src/components/tree/tree.test.tsx +699 -0
- package/src/components/tree/tree.tsx +1149 -0
- package/src/components/tree/use-tree-keyboard.ts +314 -0
- package/src/components/tree-select/index.ts +1 -0
- package/src/components/tree-select/tree-select.stories.tsx +127 -0
- package/src/components/tree-select/tree-select.test.tsx +40 -0
- package/src/components/tree-select/tree-select.tsx +137 -0
- package/src/components/typography/index.ts +31 -0
- package/src/components/typography/prose.test.tsx +48 -0
- package/src/components/typography/prose.tsx +194 -0
- package/src/components/typography/typography.stories.tsx +244 -0
- package/src/components/typography/typography.test.tsx +140 -0
- package/src/components/typography/typography.tsx +126 -0
- package/src/components/view-toolbar/index.ts +10 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +342 -0
- package/src/components/view-toolbar/view-toolbar.test.tsx +262 -0
- package/src/components/view-toolbar/view-toolbar.tsx +320 -0
- package/src/components/virtual-select/index.ts +2 -0
- package/src/components/virtual-select/use-virtual-listbox.ts +132 -0
- package/src/components/virtual-select/virtual-select.stories.tsx +203 -0
- package/src/components/virtual-select/virtual-select.test.tsx +106 -0
- package/src/components/virtual-select/virtual-select.tsx +361 -0
- package/src/components/wizard/index.ts +12 -0
- package/src/components/wizard/wizard.stories.tsx +148 -0
- package/src/components/wizard/wizard.test.tsx +67 -0
- package/src/components/wizard/wizard.tsx +354 -0
- package/src/decoration.stories.tsx +236 -0
- package/src/index.ts +174 -0
- package/src/lib/cn.test.ts +39 -0
- package/src/lib/cn.ts +31 -0
- package/src/lib/compact-number.ts +61 -0
- package/src/lib/document-address.test.ts +75 -0
- package/src/lib/document-address.ts +265 -0
- package/src/lib/download.ts +93 -0
- package/src/lib/file-icon.test.ts +45 -0
- package/src/lib/file-icon.ts +58 -0
- package/src/lib/file-kind.test.ts +98 -0
- package/src/lib/file-kind.ts +303 -0
- package/src/lib/file-source.test.ts +217 -0
- package/src/lib/file-source.ts +269 -0
- package/src/lib/merge-refs.test.ts +37 -0
- package/src/lib/merge-refs.ts +17 -0
- package/src/lib/streamdown-translations.ts +99 -0
- package/src/lib/use-copy-to-clipboard.ts +75 -0
- package/src/lib/use-mobile.ts +16 -0
- package/src/motion.stories.tsx +266 -0
- package/src/taste-profile.test.tsx +252 -0
- package/src/templates-object-detail-hub.stories.tsx +466 -0
- package/src/templates-screen-states.stories.tsx +306 -0
- package/src/templates-settings.stories.tsx +245 -0
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RevisionTimeline — a presentational git-history timeline.
|
|
5
|
+
*
|
|
6
|
+
* Renders revisions (newest-first) with:
|
|
7
|
+
* - Day grouping by calendar day from `timestamp`
|
|
8
|
+
* - An SVG lane gutter for branch-graph lanes and merge joins
|
|
9
|
+
* - Churn indicators (+additions / -deletions)
|
|
10
|
+
* - A selection rail (controlled + uncontrolled)
|
|
11
|
+
*
|
|
12
|
+
* The APP computes the graph layout (lane indices, edges). This component
|
|
13
|
+
* only renders what it receives. Heavy graph-math stays out.
|
|
14
|
+
*
|
|
15
|
+
* GATE NOTES:
|
|
16
|
+
* - No identifier starting with `Timeline` (timeline-fork class 1).
|
|
17
|
+
* - Lanes/connectors drawn with SVG <line> / <path>, NOT `absolute w-px`
|
|
18
|
+
* hairlines (timeline-fork class 2 avoidance).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
createContext,
|
|
23
|
+
forwardRef,
|
|
24
|
+
use,
|
|
25
|
+
useCallback,
|
|
26
|
+
useMemo,
|
|
27
|
+
useState,
|
|
28
|
+
type HTMLAttributes,
|
|
29
|
+
} from "react";
|
|
30
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
31
|
+
import { ChevronRight, GitBranch, GitCommitHorizontal, GitMerge, Plus, Minus } from "lucide-react";
|
|
32
|
+
import { cn } from "../../lib/cn";
|
|
33
|
+
|
|
34
|
+
// ─── Public data types ────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A single revision entry. The APP assigns `lane` (0-based column index),
|
|
38
|
+
* computes `additions`/`deletions`, and orders revisions newest-first.
|
|
39
|
+
*/
|
|
40
|
+
export interface Revision {
|
|
41
|
+
/** Unique identifier — used for selection + edge references. */
|
|
42
|
+
id: string;
|
|
43
|
+
/** Commit message (may be long; the component truncates). */
|
|
44
|
+
message: string;
|
|
45
|
+
/** Display name of the commit author. */
|
|
46
|
+
author?: string;
|
|
47
|
+
/** Avatar image URL for the author. */
|
|
48
|
+
authorAvatar?: string;
|
|
49
|
+
/** When the commit was authored. Accepts Date, ISO string, or epoch ms. */
|
|
50
|
+
timestamp: Date | string | number;
|
|
51
|
+
/** Short commit hash (e.g. "a1b2c3d"). */
|
|
52
|
+
shortSha?: string;
|
|
53
|
+
/** Lines added (churn indicator). */
|
|
54
|
+
additions?: number;
|
|
55
|
+
/** Lines deleted (churn indicator). */
|
|
56
|
+
deletions?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Column index assigned by the app (0-based). Lane 0 = main branch (leftmost).
|
|
59
|
+
* Used to position the node dot and draw the lane spine in the SVG gutter.
|
|
60
|
+
*/
|
|
61
|
+
lane: number;
|
|
62
|
+
/** Branch or tag labels rendered as chips on this revision. */
|
|
63
|
+
refs?: string[];
|
|
64
|
+
/**
|
|
65
|
+
* Branch identity for the collapse/expand mode. Opt-in: only takes effect when
|
|
66
|
+
* a matching `branches` entry is supplied to `RevisionTimeline`. When omitted
|
|
67
|
+
* (or unmatched), the revision renders as a normal, never-collapsible row.
|
|
68
|
+
*/
|
|
69
|
+
branchId?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A parent link between two revisions, for the lane graph.
|
|
74
|
+
* Used to draw merge joins (curves / angled lines) in the SVG gutter.
|
|
75
|
+
*/
|
|
76
|
+
export interface RevisionEdge {
|
|
77
|
+
/** Id of the child revision (newer, higher in the list). */
|
|
78
|
+
from: string;
|
|
79
|
+
/** Id of the parent revision (older, lower in the list). */
|
|
80
|
+
to: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A collapsible branch in the history. The APP supplies these (it already
|
|
85
|
+
* computes lanes + edges); listing a branch here makes it collapsible. Nesting
|
|
86
|
+
* via `parentBranchId` lets a parent branch's collapse also fold its descendants
|
|
87
|
+
* (multi-level). Omitting a branch from this list keeps its commits permanently
|
|
88
|
+
* visible (e.g. the trunk).
|
|
89
|
+
*/
|
|
90
|
+
export interface RevisionBranch {
|
|
91
|
+
/** Stable id, referenced by `Revision.branchId` and other branches' `parentBranchId`. */
|
|
92
|
+
id: string;
|
|
93
|
+
/** Human label for the collapsed summary row. Defaults to `id`. */
|
|
94
|
+
name?: string;
|
|
95
|
+
/** Parent branch id — enables multi-level nesting in the collapse hierarchy. */
|
|
96
|
+
parentBranchId?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Variants ────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** Visual density variants for the timeline. */
|
|
102
|
+
export const revisionTimelineVariants = cva("flex flex-col", {
|
|
103
|
+
variants: {
|
|
104
|
+
density: {
|
|
105
|
+
comfortable: "gap-0",
|
|
106
|
+
compact: "gap-0",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
defaultVariants: { density: "comfortable" },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export type RevisionTimelineDensity = NonNullable<
|
|
113
|
+
VariantProps<typeof revisionTimelineVariants>["density"]
|
|
114
|
+
>;
|
|
115
|
+
|
|
116
|
+
// ─── Lane geometry helpers ────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The number of chart palette colors. Lane index is wrapped modulo this count,
|
|
120
|
+
* so `lane % LANE_PALETTE_SIZE` never exceeds the token range 1-5.
|
|
121
|
+
*/
|
|
122
|
+
const LANE_PALETTE_SIZE = 5;
|
|
123
|
+
|
|
124
|
+
/** Lane index → CSS token string (`var(--chart-1)` … `var(--chart-5)`). */
|
|
125
|
+
export function laneColor(lane: number): string {
|
|
126
|
+
return `var(--chart-${(lane % LANE_PALETTE_SIZE) + 1})`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Width of one lane column in the SVG gutter (px). */
|
|
130
|
+
const LANE_W = 16;
|
|
131
|
+
/** Row height in the SVG gutter — must match the rendered row height. */
|
|
132
|
+
const ROW_H_COMFORTABLE = 48;
|
|
133
|
+
const ROW_H_COMPACT = 36;
|
|
134
|
+
/**
|
|
135
|
+
* Fixed height of a day-group header. MUST match the rendered `DayHeader`'s box
|
|
136
|
+
* so the SVG gutter's node dots stay aligned with their list rows once a header
|
|
137
|
+
* inserts vertical space between groups (otherwise the graph desyncs from the
|
|
138
|
+
* rows — the gutter and the list share one coordinate space).
|
|
139
|
+
*/
|
|
140
|
+
const DAY_HEADER_H = 32;
|
|
141
|
+
/** Radius of the node dot. */
|
|
142
|
+
const NODE_R = 4;
|
|
143
|
+
/**
|
|
144
|
+
* Vertical length (px) the connector runs straight along a lane before turning
|
|
145
|
+
* into the adjacent lane. A FIXED corner length (not a proportion of the row
|
|
146
|
+
* span) makes a cross-lane join read as "leave the lane → short elbow → arrive",
|
|
147
|
+
* so a branch clearly leads down into its parent lane instead of meandering
|
|
148
|
+
* across the whole gap with a lazy midpoint S-curve.
|
|
149
|
+
*/
|
|
150
|
+
const EDGE_TURN = 16;
|
|
151
|
+
/**
|
|
152
|
+
* One opacity for BOTH lane spines and cross-lane connectors so a fork/merge join
|
|
153
|
+
* reads as one continuous line — a mismatch here leaves a visible seam exactly
|
|
154
|
+
* where a branch should be leading into the previous level.
|
|
155
|
+
*/
|
|
156
|
+
const LANE_STROKE_OPACITY = 0.75;
|
|
157
|
+
|
|
158
|
+
/** X centre of a lane column in the SVG. */
|
|
159
|
+
function laneX(lane: number): number {
|
|
160
|
+
return lane * LANE_W + LANE_W / 2;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Branch collapse model ────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The minimal node shape the SVG gutter needs (lane position + id). Both real
|
|
167
|
+
* revisions and synthetic collapsed-branch summary nodes satisfy this.
|
|
168
|
+
*/
|
|
169
|
+
interface GutterNode {
|
|
170
|
+
id: string;
|
|
171
|
+
lane: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A list/gutter render item. Either a real revision row or a single summary row
|
|
176
|
+
* standing in for a collapsed branch (placed at the branch's tip position).
|
|
177
|
+
*/
|
|
178
|
+
type RenderItem =
|
|
179
|
+
| {
|
|
180
|
+
kind: "revision";
|
|
181
|
+
revision: Revision;
|
|
182
|
+
/** True iff this row is the tip of an expanded, collapsible branch. */
|
|
183
|
+
canCollapse: boolean;
|
|
184
|
+
branchId?: string;
|
|
185
|
+
}
|
|
186
|
+
| {
|
|
187
|
+
kind: "summary";
|
|
188
|
+
branch: RevisionBranch;
|
|
189
|
+
/** Hidden revisions in this branch's subtree (no double counting). */
|
|
190
|
+
count: number;
|
|
191
|
+
/** Lane the summary node sits on (the tip's lane). */
|
|
192
|
+
lane: number;
|
|
193
|
+
/** Synthetic, collision-free id (`summary:<branchId>`). */
|
|
194
|
+
id: string;
|
|
195
|
+
/** Tip timestamp — used for day grouping so the summary lands in the tip's day. */
|
|
196
|
+
tipTimestamp: Date | string | number;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
interface BranchMaps {
|
|
200
|
+
branchById: Map<string, RevisionBranch>;
|
|
201
|
+
parentOf: Map<string, string | undefined>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Index `branches` for O(1) lookup + parent-chain walks. Pure. */
|
|
205
|
+
function buildBranchMaps(branches: RevisionBranch[]): BranchMaps {
|
|
206
|
+
const branchById = new Map<string, RevisionBranch>();
|
|
207
|
+
const parentOf = new Map<string, string | undefined>();
|
|
208
|
+
for (const b of branches) {
|
|
209
|
+
branchById.set(b.id, b);
|
|
210
|
+
parentOf.set(b.id, b.parentBranchId);
|
|
211
|
+
}
|
|
212
|
+
return { branchById, parentOf };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Returns a memoised lookup: branch id → the TOP-MOST collapsed ancestor on its
|
|
217
|
+
* parent chain (itself included), or `undefined` if nothing in the chain is
|
|
218
|
+
* collapsed. Walking leaf→root and keeping the last collapsed hit yields the
|
|
219
|
+
* highest collapsed ancestor, which is the branch whose summary absorbs the row.
|
|
220
|
+
* Cycle-guarded (defensive against malformed app data).
|
|
221
|
+
*/
|
|
222
|
+
function makeTopCollapsed(parentOf: Map<string, string | undefined>, collapsedSet: Set<string>) {
|
|
223
|
+
const cache = new Map<string, string | undefined>();
|
|
224
|
+
return function topCollapsed(branchId: string): string | undefined {
|
|
225
|
+
const cached = cache.get(branchId);
|
|
226
|
+
if (cached !== undefined || cache.has(branchId)) return cached;
|
|
227
|
+
let topMost: string | undefined;
|
|
228
|
+
const visited = new Set<string>();
|
|
229
|
+
let cur: string | undefined = branchId;
|
|
230
|
+
while (cur !== undefined && !visited.has(cur)) {
|
|
231
|
+
visited.add(cur);
|
|
232
|
+
if (collapsedSet.has(cur)) topMost = cur;
|
|
233
|
+
cur = parentOf.get(cur);
|
|
234
|
+
}
|
|
235
|
+
cache.set(branchId, topMost);
|
|
236
|
+
return topMost;
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface ComputeResult {
|
|
241
|
+
items: RenderItem[];
|
|
242
|
+
/** Hidden revision id → the summary item id that absorbed it (for edge remap). */
|
|
243
|
+
summaryOf: Map<string, string>;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Single newest-first pass over `revisions` → the visible render-item list.
|
|
248
|
+
*
|
|
249
|
+
* A revision is hidden when its branch (or any ancestor branch) is collapsed; the
|
|
250
|
+
* FIRST hidden revision of a collapsed subtree (the tip, since order is
|
|
251
|
+
* newest-first) is replaced by ONE summary row at its slot, and the rest are
|
|
252
|
+
* dropped. Each hidden revision is counted against its top-most collapsed
|
|
253
|
+
* ancestor only, so nested collapse never double-counts. Light: Map/Set lookups
|
|
254
|
+
* and a shallow parent-chain walk — no graph math.
|
|
255
|
+
*/
|
|
256
|
+
function computeRenderItems(
|
|
257
|
+
revisions: Revision[],
|
|
258
|
+
collapsedSet: Set<string>,
|
|
259
|
+
maps: BranchMaps,
|
|
260
|
+
): ComputeResult {
|
|
261
|
+
const items: RenderItem[] = [];
|
|
262
|
+
const summaryOf = new Map<string, string>();
|
|
263
|
+
const summaryCount = new Map<string, number>();
|
|
264
|
+
const summaryEmitted = new Set<string>();
|
|
265
|
+
const seenVisibleBranch = new Set<string>();
|
|
266
|
+
const topCollapsed = makeTopCollapsed(maps.parentOf, collapsedSet);
|
|
267
|
+
|
|
268
|
+
for (const rev of revisions) {
|
|
269
|
+
const bid = rev.branchId;
|
|
270
|
+
// Unknown branch ids (not in `branches`) are never hidden / never collapsible.
|
|
271
|
+
if (bid !== undefined && maps.branchById.has(bid)) {
|
|
272
|
+
const top = topCollapsed(bid);
|
|
273
|
+
if (top !== undefined) {
|
|
274
|
+
const summaryId = `summary:${top}`;
|
|
275
|
+
summaryOf.set(rev.id, summaryId);
|
|
276
|
+
summaryCount.set(top, (summaryCount.get(top) ?? 0) + 1);
|
|
277
|
+
if (!summaryEmitted.has(top)) {
|
|
278
|
+
summaryEmitted.add(top);
|
|
279
|
+
const branch = maps.branchById.get(top) ?? { id: top };
|
|
280
|
+
items.push({
|
|
281
|
+
kind: "summary",
|
|
282
|
+
branch,
|
|
283
|
+
count: 0,
|
|
284
|
+
lane: rev.lane,
|
|
285
|
+
id: summaryId,
|
|
286
|
+
tipTimestamp: rev.timestamp,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const canCollapse = !seenVisibleBranch.has(bid);
|
|
292
|
+
seenVisibleBranch.add(bid);
|
|
293
|
+
items.push({ kind: "revision", revision: rev, canCollapse, branchId: bid });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
items.push({ kind: "revision", revision: rev, canCollapse: false, branchId: bid });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const it of items) {
|
|
301
|
+
if (it.kind === "summary") it.count = summaryCount.get(it.branch.id) ?? 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { items, summaryOf };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── Context ─────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
interface RevisionTimelineCtx {
|
|
310
|
+
selectedId: string | undefined;
|
|
311
|
+
onSelect: (id: string) => void;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const RevisionTimelineContext = createContext<RevisionTimelineCtx | null>(null);
|
|
315
|
+
|
|
316
|
+
function useRevisionTimelineCtx(): RevisionTimelineCtx {
|
|
317
|
+
const ctx = use(RevisionTimelineContext);
|
|
318
|
+
if (!ctx) throw new Error("RevisionTimeline sub-parts must be inside <RevisionTimeline>");
|
|
319
|
+
return ctx;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─── SVG lane gutter ─────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
interface LaneGutterProps {
|
|
325
|
+
revisions: GutterNode[];
|
|
326
|
+
edges: RevisionEdge[];
|
|
327
|
+
/**
|
|
328
|
+
* Map of revision id → vertical centre (px) in the gutter's coordinate space.
|
|
329
|
+
* Computed by the parent so the gutter shares ONE coordinate space with the
|
|
330
|
+
* list — it already accounts for day-header heights, so dots line up with
|
|
331
|
+
* their rows even when `groupBy="day"` inserts headers between groups.
|
|
332
|
+
*/
|
|
333
|
+
rowCenterY: Map<string, number>;
|
|
334
|
+
/** Total gutter height (px) — equals the rendered list height. */
|
|
335
|
+
height: number;
|
|
336
|
+
className?: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Renders the branch-graph SVG gutter. Draws:
|
|
341
|
+
* - A vertical spine per lane (coloured via chart tokens)
|
|
342
|
+
* - A filled circle node at each revision's lane position
|
|
343
|
+
* - Merge-join cubic-bezier curves between a child revision and its parent
|
|
344
|
+
* revision when they are in different lanes (cross-lane edges)
|
|
345
|
+
*
|
|
346
|
+
* All Y positions come from `rowCenterY` (header-aware), NOT `index * ROW_H`,
|
|
347
|
+
* so the graph stays aligned with day-grouped rows. No `absolute w-px`
|
|
348
|
+
* connectors — all drawing is SVG (timeline-fork safe).
|
|
349
|
+
*/
|
|
350
|
+
function LaneGutter({ revisions, edges, rowCenterY, height, className }: LaneGutterProps) {
|
|
351
|
+
const maxLane = revisions.reduce((m, r) => Math.max(m, r.lane), 0);
|
|
352
|
+
const svgW = (maxLane + 1) * LANE_W;
|
|
353
|
+
|
|
354
|
+
const idToRev = useMemo(() => {
|
|
355
|
+
const m = new Map<string, GutterNode>();
|
|
356
|
+
for (const r of revisions) m.set(r.id, r);
|
|
357
|
+
return m;
|
|
358
|
+
}, [revisions]);
|
|
359
|
+
|
|
360
|
+
// Each lane's first + last revision (in list order) bound its spine.
|
|
361
|
+
const laneSpan = useMemo(() => {
|
|
362
|
+
const first = new Map<number, string>();
|
|
363
|
+
const last = new Map<number, string>();
|
|
364
|
+
for (const r of revisions) {
|
|
365
|
+
if (!first.has(r.lane)) first.set(r.lane, r.id);
|
|
366
|
+
last.set(r.lane, r.id);
|
|
367
|
+
}
|
|
368
|
+
return { first, last };
|
|
369
|
+
}, [revisions]);
|
|
370
|
+
|
|
371
|
+
// Pre-count incoming edges per revision → merge detection.
|
|
372
|
+
const incomingCount = useMemo(() => {
|
|
373
|
+
const m = new Map<string, number>();
|
|
374
|
+
for (const e of edges) m.set(e.to, (m.get(e.to) ?? 0) + 1);
|
|
375
|
+
return m;
|
|
376
|
+
}, [edges]);
|
|
377
|
+
|
|
378
|
+
if (height === 0 || revisions.length === 0) return null;
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<svg
|
|
382
|
+
aria-hidden="true"
|
|
383
|
+
width={svgW}
|
|
384
|
+
height={height}
|
|
385
|
+
className={cn("shrink-0 self-stretch", className)}
|
|
386
|
+
style={{ minWidth: svgW }}
|
|
387
|
+
>
|
|
388
|
+
{/* Lane spines — one vertical line per lane from first to last revision */}
|
|
389
|
+
{Array.from(laneSpan.last.entries()).map(([lane, lastId]) => {
|
|
390
|
+
const firstId = laneSpan.first.get(lane);
|
|
391
|
+
const y1 = firstId ? rowCenterY.get(firstId) : undefined;
|
|
392
|
+
const y2 = rowCenterY.get(lastId);
|
|
393
|
+
if (y1 === undefined || y2 === undefined) return null;
|
|
394
|
+
const x = laneX(lane);
|
|
395
|
+
return (
|
|
396
|
+
<line
|
|
397
|
+
key={`spine-${lane}`}
|
|
398
|
+
x1={x}
|
|
399
|
+
y1={y1}
|
|
400
|
+
x2={x}
|
|
401
|
+
y2={y2}
|
|
402
|
+
stroke={laneColor(lane)}
|
|
403
|
+
strokeWidth={2}
|
|
404
|
+
strokeLinecap="round"
|
|
405
|
+
opacity={LANE_STROKE_OPACITY}
|
|
406
|
+
/>
|
|
407
|
+
);
|
|
408
|
+
})}
|
|
409
|
+
|
|
410
|
+
{/* Edge connectors — cross-lane merge / fork joins. The line stays in its
|
|
411
|
+
source lane for a fixed length (EDGE_TURN), turns through a short elbow,
|
|
412
|
+
then runs straight into the target lane — so it leads cleanly into the
|
|
413
|
+
previous level instead of drifting across the whole gap. */}
|
|
414
|
+
{edges.map((edge, ei) => {
|
|
415
|
+
const fromRev = idToRev.get(edge.from);
|
|
416
|
+
const toRev = idToRev.get(edge.to);
|
|
417
|
+
if (!fromRev || !toRev) return null;
|
|
418
|
+
if (fromRev.lane === toRev.lane) return null; // covered by the spine
|
|
419
|
+
const y1 = rowCenterY.get(fromRev.id);
|
|
420
|
+
const y2 = rowCenterY.get(toRev.id);
|
|
421
|
+
if (y1 === undefined || y2 === undefined) return null;
|
|
422
|
+
const x1 = laneX(fromRev.lane);
|
|
423
|
+
const x2 = laneX(toRev.lane);
|
|
424
|
+
const dir = y2 >= y1 ? 1 : -1;
|
|
425
|
+
// Tangent the curve to BOTH lanes (vertical at each end) with a fixed
|
|
426
|
+
// corner length, capped so short joins don't overshoot.
|
|
427
|
+
const k = Math.min(Math.abs(y2 - y1) / 2, EDGE_TURN);
|
|
428
|
+
return (
|
|
429
|
+
<path
|
|
430
|
+
key={`edge-${ei}`}
|
|
431
|
+
d={`M ${x1} ${y1} C ${x1} ${y1 + dir * k}, ${x2} ${y2 - dir * k}, ${x2} ${y2}`}
|
|
432
|
+
stroke={laneColor(fromRev.lane)}
|
|
433
|
+
strokeWidth={2}
|
|
434
|
+
fill="none"
|
|
435
|
+
strokeLinecap="round"
|
|
436
|
+
opacity={LANE_STROKE_OPACITY}
|
|
437
|
+
/>
|
|
438
|
+
);
|
|
439
|
+
})}
|
|
440
|
+
|
|
441
|
+
{/* Node dots — one circle per revision */}
|
|
442
|
+
{revisions.map((rev) => {
|
|
443
|
+
const x = laneX(rev.lane);
|
|
444
|
+
const y = rowCenterY.get(rev.id);
|
|
445
|
+
if (y === undefined) return null;
|
|
446
|
+
const isMerge = (incomingCount.get(rev.id) ?? 0) >= 2;
|
|
447
|
+
return (
|
|
448
|
+
<g key={`node-${rev.id}`}>
|
|
449
|
+
{isMerge ? (
|
|
450
|
+
<>
|
|
451
|
+
<circle
|
|
452
|
+
cx={x}
|
|
453
|
+
cy={y}
|
|
454
|
+
r={NODE_R + 2}
|
|
455
|
+
fill="var(--background)"
|
|
456
|
+
stroke={laneColor(rev.lane)}
|
|
457
|
+
strokeWidth={2}
|
|
458
|
+
/>
|
|
459
|
+
<circle cx={x} cy={y} r={NODE_R - 1} fill={laneColor(rev.lane)} />
|
|
460
|
+
</>
|
|
461
|
+
) : (
|
|
462
|
+
<circle cx={x} cy={y} r={NODE_R} fill={laneColor(rev.lane)} />
|
|
463
|
+
)}
|
|
464
|
+
</g>
|
|
465
|
+
);
|
|
466
|
+
})}
|
|
467
|
+
</svg>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ─── Day group header ─────────────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
interface DayHeaderProps {
|
|
474
|
+
label: string;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function DayHeader({ label }: DayHeaderProps) {
|
|
478
|
+
// Fixed height (DAY_HEADER_H) so the SVG gutter's coordinate space stays in
|
|
479
|
+
// lockstep with the rendered rows — see LaneGutter / rowCenterY.
|
|
480
|
+
return (
|
|
481
|
+
<div
|
|
482
|
+
role="separator"
|
|
483
|
+
aria-label={label}
|
|
484
|
+
style={{ height: DAY_HEADER_H }}
|
|
485
|
+
className="box-border flex items-center gap-3 px-3"
|
|
486
|
+
>
|
|
487
|
+
<span className="text-meta font-medium uppercase tracking-wide text-muted-foreground">
|
|
488
|
+
{label}
|
|
489
|
+
</span>
|
|
490
|
+
<div className="h-px flex-1 bg-border" aria-hidden="true" />
|
|
491
|
+
</div>
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ─── Churn indicator ──────────────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
interface ChurnProps {
|
|
498
|
+
additions?: number;
|
|
499
|
+
deletions?: number;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function Churn({ additions, deletions }: ChurnProps) {
|
|
503
|
+
const hasAdditions = additions !== undefined && additions > 0;
|
|
504
|
+
const hasDeletions = deletions !== undefined && deletions > 0;
|
|
505
|
+
if (!hasAdditions && !hasDeletions) return null;
|
|
506
|
+
|
|
507
|
+
return (
|
|
508
|
+
<span
|
|
509
|
+
className="flex shrink-0 items-center gap-1 tabular-nums text-meta text-muted-foreground"
|
|
510
|
+
aria-label={`${additions ?? 0} additions, ${deletions ?? 0} deletions`}
|
|
511
|
+
>
|
|
512
|
+
{hasAdditions && (
|
|
513
|
+
<span className="flex items-center gap-0.5 text-success-text" aria-hidden="true">
|
|
514
|
+
<Plus aria-hidden="true" className="size-2.5" />
|
|
515
|
+
{additions}
|
|
516
|
+
</span>
|
|
517
|
+
)}
|
|
518
|
+
{hasDeletions && (
|
|
519
|
+
<span className="flex items-center gap-0.5 text-destructive" aria-hidden="true">
|
|
520
|
+
<Minus aria-hidden="true" className="size-2.5" />
|
|
521
|
+
{deletions}
|
|
522
|
+
</span>
|
|
523
|
+
)}
|
|
524
|
+
</span>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ─── Ref chip ─────────────────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
function RefChip({ label }: { label: string }) {
|
|
531
|
+
return (
|
|
532
|
+
<span className="inline-flex shrink-0 items-center rounded border border-border bg-muted px-1.5 py-0.5 text-meta text-muted-foreground">
|
|
533
|
+
{label}
|
|
534
|
+
</span>
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ─── Branch collapse UI ───────────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
/** A right-chevron that rotates 90° to point down when its branch is expanded. */
|
|
541
|
+
function BranchChevron({ expanded }: { expanded: boolean }) {
|
|
542
|
+
return (
|
|
543
|
+
<ChevronRight
|
|
544
|
+
aria-hidden="true"
|
|
545
|
+
className={cn(
|
|
546
|
+
"size-3.5 shrink-0 text-muted-foreground transition-transform duration-fast ease-standard motion-reduce:transition-none",
|
|
547
|
+
expanded && "rotate-90",
|
|
548
|
+
)}
|
|
549
|
+
/>
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* The collapse toggle shown on an expanded branch's tip row. A standalone button
|
|
555
|
+
* (a sibling of the row's select button — never nested inside it) so there are no
|
|
556
|
+
* nested interactive elements.
|
|
557
|
+
*/
|
|
558
|
+
function BranchToggle({ branchName, onToggle }: { branchName: string; onToggle: () => void }) {
|
|
559
|
+
return (
|
|
560
|
+
<button
|
|
561
|
+
type="button"
|
|
562
|
+
aria-expanded={true}
|
|
563
|
+
aria-label={`Collapse branch ${branchName}`}
|
|
564
|
+
onClick={onToggle}
|
|
565
|
+
className={cn(
|
|
566
|
+
"flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground",
|
|
567
|
+
"transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
568
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
569
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
570
|
+
)}
|
|
571
|
+
>
|
|
572
|
+
<BranchChevron expanded />
|
|
573
|
+
</button>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
interface CollapsedBranchRowProps {
|
|
578
|
+
branchId: string;
|
|
579
|
+
branchName: string;
|
|
580
|
+
/** Pre-formatted trailing label, e.g. "3 commits" (default) or "3 steps". */
|
|
581
|
+
summaryLabel: string;
|
|
582
|
+
density: RevisionTimelineDensity;
|
|
583
|
+
/** Left indent (px), proportional to the collapsed branch's lane depth. */
|
|
584
|
+
indent: number;
|
|
585
|
+
onExpand: () => void;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The single summary row that stands in for a collapsed branch, placed at the
|
|
590
|
+
* branch's tip position. The whole row IS the expand button (no nested
|
|
591
|
+
* interactives), so it offers exactly one keyboard/AT target.
|
|
592
|
+
*/
|
|
593
|
+
function CollapsedBranchRow({
|
|
594
|
+
branchId,
|
|
595
|
+
branchName,
|
|
596
|
+
summaryLabel,
|
|
597
|
+
density,
|
|
598
|
+
indent,
|
|
599
|
+
onExpand,
|
|
600
|
+
}: CollapsedBranchRowProps) {
|
|
601
|
+
const ROW_H = density === "compact" ? ROW_H_COMPACT : ROW_H_COMFORTABLE;
|
|
602
|
+
|
|
603
|
+
return (
|
|
604
|
+
<li
|
|
605
|
+
role="listitem"
|
|
606
|
+
data-summary-branch-id={branchId}
|
|
607
|
+
style={{ height: ROW_H, paddingInlineStart: indent }}
|
|
608
|
+
className="relative flex items-center"
|
|
609
|
+
>
|
|
610
|
+
<button
|
|
611
|
+
type="button"
|
|
612
|
+
aria-expanded={false}
|
|
613
|
+
aria-label={`Collapsed branch ${branchName}, ${summaryLabel}, expand`}
|
|
614
|
+
onClick={onExpand}
|
|
615
|
+
className={cn(
|
|
616
|
+
"flex h-full w-full min-w-0 items-center gap-2 px-2 text-start",
|
|
617
|
+
"transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
618
|
+
"rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
619
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
620
|
+
)}
|
|
621
|
+
>
|
|
622
|
+
<BranchChevron expanded={false} />
|
|
623
|
+
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
624
|
+
<span className="min-w-0 flex-1 truncate text-body text-foreground" title={branchName}>
|
|
625
|
+
{branchName}
|
|
626
|
+
</span>
|
|
627
|
+
<span className="shrink-0 tabular-nums text-meta text-muted-foreground">
|
|
628
|
+
{summaryLabel}
|
|
629
|
+
</span>
|
|
630
|
+
</button>
|
|
631
|
+
</li>
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ─── Revision row ────────────────────────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
interface RevisionRowProps {
|
|
638
|
+
revision: Revision;
|
|
639
|
+
isMerge: boolean;
|
|
640
|
+
density: RevisionTimelineDensity;
|
|
641
|
+
/**
|
|
642
|
+
* Left indent (px) for this row's content, proportional to its lane depth, so
|
|
643
|
+
* the row text staircases alongside the branch graph instead of all rows
|
|
644
|
+
* starting at the same point.
|
|
645
|
+
*/
|
|
646
|
+
indent: number;
|
|
647
|
+
/**
|
|
648
|
+
* When set, this row is the tip of an expanded collapsible branch: render a
|
|
649
|
+
* collapse toggle as a sibling of the select button (never nested).
|
|
650
|
+
*/
|
|
651
|
+
collapseToggle?: { branchName: string; onToggle: () => void };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function RevisionRow({ revision, isMerge, density, indent, collapseToggle }: RevisionRowProps) {
|
|
655
|
+
const { selectedId, onSelect } = useRevisionTimelineCtx();
|
|
656
|
+
const isSelected = selectedId === revision.id;
|
|
657
|
+
const ROW_H = density === "compact" ? ROW_H_COMPACT : ROW_H_COMFORTABLE;
|
|
658
|
+
|
|
659
|
+
const formattedTimestamp = useMemo(() => {
|
|
660
|
+
const d =
|
|
661
|
+
revision.timestamp instanceof Date ? revision.timestamp : new Date(revision.timestamp);
|
|
662
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
663
|
+
hour: "2-digit",
|
|
664
|
+
minute: "2-digit",
|
|
665
|
+
}).format(d);
|
|
666
|
+
}, [revision.timestamp]);
|
|
667
|
+
|
|
668
|
+
const fullTimestamp = useMemo(() => {
|
|
669
|
+
const d =
|
|
670
|
+
revision.timestamp instanceof Date ? revision.timestamp : new Date(revision.timestamp);
|
|
671
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
672
|
+
dateStyle: "full",
|
|
673
|
+
timeStyle: "medium",
|
|
674
|
+
}).format(d);
|
|
675
|
+
}, [revision.timestamp]);
|
|
676
|
+
|
|
677
|
+
return (
|
|
678
|
+
<li
|
|
679
|
+
role="listitem"
|
|
680
|
+
data-revision-id={revision.id}
|
|
681
|
+
data-selected={isSelected || undefined}
|
|
682
|
+
style={{ height: ROW_H, paddingInlineStart: indent }}
|
|
683
|
+
className="relative flex items-center"
|
|
684
|
+
>
|
|
685
|
+
{collapseToggle && (
|
|
686
|
+
<BranchToggle branchName={collapseToggle.branchName} onToggle={collapseToggle.onToggle} />
|
|
687
|
+
)}
|
|
688
|
+
<button
|
|
689
|
+
type="button"
|
|
690
|
+
aria-label={`Select revision: ${revision.message}`}
|
|
691
|
+
aria-pressed={isSelected}
|
|
692
|
+
onClick={() => onSelect(revision.id)}
|
|
693
|
+
className={cn(
|
|
694
|
+
collapseToggle
|
|
695
|
+
? "flex h-full min-w-0 flex-1 items-center gap-2 px-2 text-start"
|
|
696
|
+
: "flex h-full w-full min-w-0 items-center gap-2 px-2 text-start",
|
|
697
|
+
"transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
698
|
+
"rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
699
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
700
|
+
isSelected && "border-s-2 border-s-primary bg-accent text-accent-foreground",
|
|
701
|
+
)}
|
|
702
|
+
>
|
|
703
|
+
{/* Merge icon (non-color visual cue for merge commits) */}
|
|
704
|
+
<span className="shrink-0 text-muted-foreground" aria-hidden="true">
|
|
705
|
+
{isMerge ? (
|
|
706
|
+
<GitMerge className="size-3.5" aria-hidden="true" />
|
|
707
|
+
) : (
|
|
708
|
+
<GitCommitHorizontal className="size-3.5" aria-hidden="true" />
|
|
709
|
+
)}
|
|
710
|
+
</span>
|
|
711
|
+
|
|
712
|
+
{/* Commit message — truncates with min-w-0 */}
|
|
713
|
+
<span
|
|
714
|
+
className="min-w-0 flex-1 truncate text-body text-foreground"
|
|
715
|
+
title={revision.message}
|
|
716
|
+
>
|
|
717
|
+
{revision.message}
|
|
718
|
+
</span>
|
|
719
|
+
|
|
720
|
+
{/* Ref chips (branch / tag labels) */}
|
|
721
|
+
{revision.refs && revision.refs.length > 0 && (
|
|
722
|
+
<span className="hidden shrink-0 items-center gap-1 sm:flex">
|
|
723
|
+
{revision.refs.map((ref) => (
|
|
724
|
+
<RefChip key={ref} label={ref} />
|
|
725
|
+
))}
|
|
726
|
+
</span>
|
|
727
|
+
)}
|
|
728
|
+
|
|
729
|
+
{/* Churn indicators */}
|
|
730
|
+
<Churn additions={revision.additions} deletions={revision.deletions} />
|
|
731
|
+
|
|
732
|
+
{/* Author */}
|
|
733
|
+
{revision.author && (
|
|
734
|
+
<span
|
|
735
|
+
className="hidden shrink-0 text-caption text-muted-foreground md:block"
|
|
736
|
+
title={revision.author}
|
|
737
|
+
>
|
|
738
|
+
{revision.author}
|
|
739
|
+
</span>
|
|
740
|
+
)}
|
|
741
|
+
|
|
742
|
+
{/* Short SHA */}
|
|
743
|
+
{revision.shortSha && (
|
|
744
|
+
<span
|
|
745
|
+
className="hidden shrink-0 font-mono text-meta text-muted-foreground sm:block"
|
|
746
|
+
title={`Full SHA: ${revision.shortSha}`}
|
|
747
|
+
>
|
|
748
|
+
{revision.shortSha}
|
|
749
|
+
</span>
|
|
750
|
+
)}
|
|
751
|
+
|
|
752
|
+
{/* Timestamp */}
|
|
753
|
+
<time
|
|
754
|
+
dateTime={
|
|
755
|
+
revision.timestamp instanceof Date
|
|
756
|
+
? revision.timestamp.toISOString()
|
|
757
|
+
: new Date(revision.timestamp).toISOString()
|
|
758
|
+
}
|
|
759
|
+
className="shrink-0 text-meta text-muted-foreground"
|
|
760
|
+
title={fullTimestamp}
|
|
761
|
+
>
|
|
762
|
+
{formattedTimestamp}
|
|
763
|
+
</time>
|
|
764
|
+
</button>
|
|
765
|
+
</li>
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ─── Collapse state hook ──────────────────────────────────────────────────────
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Controlled/uncontrolled collapsed-branch state — mirrors the selection pattern.
|
|
773
|
+
* Internally a `Set`; the controlled API stays array-shaped. `toggleBranch`
|
|
774
|
+
* fires `onBranchToggle(id, next)` and only mutates internal state when
|
|
775
|
+
* uncontrolled.
|
|
776
|
+
*/
|
|
777
|
+
function useBranchCollapse(opts: {
|
|
778
|
+
collapsedBranchIds?: string[];
|
|
779
|
+
defaultCollapsedBranchIds?: string[];
|
|
780
|
+
onBranchToggle?: (branchId: string, collapsed: boolean) => void;
|
|
781
|
+
}) {
|
|
782
|
+
const { collapsedBranchIds, defaultCollapsedBranchIds, onBranchToggle } = opts;
|
|
783
|
+
const isControlled = collapsedBranchIds !== undefined;
|
|
784
|
+
const [internal, setInternal] = useState<Set<string>>(
|
|
785
|
+
() => new Set(defaultCollapsedBranchIds ?? []),
|
|
786
|
+
);
|
|
787
|
+
const collapsedSet = useMemo(
|
|
788
|
+
() => (isControlled ? new Set(collapsedBranchIds) : internal),
|
|
789
|
+
[isControlled, collapsedBranchIds, internal],
|
|
790
|
+
);
|
|
791
|
+
const toggleBranch = useCallback(
|
|
792
|
+
(branchId: string) => {
|
|
793
|
+
const next = !collapsedSet.has(branchId);
|
|
794
|
+
onBranchToggle?.(branchId, next);
|
|
795
|
+
if (!isControlled) {
|
|
796
|
+
setInternal((prev) => {
|
|
797
|
+
const s = new Set(prev);
|
|
798
|
+
if (next) s.add(branchId);
|
|
799
|
+
else s.delete(branchId);
|
|
800
|
+
return s;
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
[collapsedSet, isControlled, onBranchToggle],
|
|
805
|
+
);
|
|
806
|
+
return { collapsedSet, toggleBranch };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ─── Main component ───────────────────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
export interface RevisionTimelineProps
|
|
812
|
+
extends
|
|
813
|
+
Omit<HTMLAttributes<HTMLDivElement>, "onSelect">,
|
|
814
|
+
VariantProps<typeof revisionTimelineVariants> {
|
|
815
|
+
/** Ordered revisions, newest → oldest. */
|
|
816
|
+
revisions: Revision[];
|
|
817
|
+
/**
|
|
818
|
+
* Parent-link edges for drawing the branch graph.
|
|
819
|
+
* Each edge connects a child revision (`from`) to its parent (`to`).
|
|
820
|
+
*/
|
|
821
|
+
edges?: RevisionEdge[];
|
|
822
|
+
/**
|
|
823
|
+
* How to group revisions. `"day"` inserts day headers; `"none"` renders a
|
|
824
|
+
* flat list. @default "day"
|
|
825
|
+
*/
|
|
826
|
+
groupBy?: "day" | "none";
|
|
827
|
+
/** Format the day-group header label. Defaults to `Intl.DateTimeFormat` long date. */
|
|
828
|
+
formatDay?: (d: Date) => string;
|
|
829
|
+
/**
|
|
830
|
+
* Controlled selection: the id of the currently-selected revision.
|
|
831
|
+
* Omit to use uncontrolled selection (`defaultSelectedId`).
|
|
832
|
+
*/
|
|
833
|
+
selectedId?: string;
|
|
834
|
+
/** Initial selected revision id for uncontrolled usage. */
|
|
835
|
+
defaultSelectedId?: string;
|
|
836
|
+
/** Called when the user selects a revision row. */
|
|
837
|
+
onSelect?: (id: string) => void;
|
|
838
|
+
/**
|
|
839
|
+
* Collapsible branch registry. **Supplying this opts in to the collapse/expand
|
|
840
|
+
* mode.** Each listed branch becomes collapsible; a branch's commits fold into a
|
|
841
|
+
* single summary row at its tip. `parentBranchId` nests branches so collapsing a
|
|
842
|
+
* parent also folds its descendants (multi-level). A revision whose `branchId`
|
|
843
|
+
* is not listed here (e.g. the trunk) stays permanently visible. Omit entirely
|
|
844
|
+
* for the classic, non-collapsible timeline.
|
|
845
|
+
*/
|
|
846
|
+
branches?: RevisionBranch[];
|
|
847
|
+
/** Controlled set of collapsed branch ids. */
|
|
848
|
+
collapsedBranchIds?: string[];
|
|
849
|
+
/** Uncontrolled initial set of collapsed branch ids. */
|
|
850
|
+
defaultCollapsedBranchIds?: string[];
|
|
851
|
+
/** Called when a branch is collapsed or expanded. `collapsed` is the NEXT state. */
|
|
852
|
+
onBranchToggle?: (branchId: string, collapsed: boolean) => void;
|
|
853
|
+
/**
|
|
854
|
+
* Trailing summary text on a collapsed branch row. Defaults to
|
|
855
|
+
* `"<n> commit(s)"`; override to re-vocabulary the timeline (e.g. `"<n> steps"`
|
|
856
|
+
* when modelling a process flow rather than git history).
|
|
857
|
+
*/
|
|
858
|
+
formatBranchSummary?: (count: number, branch: RevisionBranch) => string;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* RevisionTimeline — presentational git-history timeline.
|
|
863
|
+
*
|
|
864
|
+
* Renders revisions in a scrollable list with:
|
|
865
|
+
* - Day grouping (collapsible via `groupBy`)
|
|
866
|
+
* - SVG branch-graph lanes + merge joins in the left gutter
|
|
867
|
+
* - Churn indicators, ref chips, author, SHA, timestamp
|
|
868
|
+
* - Accessible selection (real `<button>` elements in an `<ol>`)
|
|
869
|
+
*
|
|
870
|
+
* Self-contained: no required Provider ancestor. Use `selectedId` +
|
|
871
|
+
* `onSelect` for controlled selection, or `defaultSelectedId` for uncontrolled.
|
|
872
|
+
*
|
|
873
|
+
* Pass `branches` to enable the collapse/expand mode: a branch's commits fold
|
|
874
|
+
* into a single summary row at its tip (multi-level via `parentBranchId`).
|
|
875
|
+
*/
|
|
876
|
+
export const RevisionTimeline = forwardRef<HTMLDivElement, RevisionTimelineProps>(
|
|
877
|
+
function RevisionTimeline(
|
|
878
|
+
{
|
|
879
|
+
revisions,
|
|
880
|
+
edges = [],
|
|
881
|
+
groupBy = "day",
|
|
882
|
+
formatDay,
|
|
883
|
+
selectedId: controlledSelectedId,
|
|
884
|
+
defaultSelectedId,
|
|
885
|
+
onSelect,
|
|
886
|
+
branches,
|
|
887
|
+
collapsedBranchIds,
|
|
888
|
+
defaultCollapsedBranchIds,
|
|
889
|
+
onBranchToggle,
|
|
890
|
+
formatBranchSummary,
|
|
891
|
+
density,
|
|
892
|
+
className,
|
|
893
|
+
...props
|
|
894
|
+
},
|
|
895
|
+
ref,
|
|
896
|
+
) {
|
|
897
|
+
const resolvedDensity: RevisionTimelineDensity = density ?? "comfortable";
|
|
898
|
+
|
|
899
|
+
// Collapse/expand mode is opt-in: active only when `branches` are supplied.
|
|
900
|
+
const collapseEnabled = !!branches && branches.length > 0;
|
|
901
|
+
const branchMaps = useMemo(() => buildBranchMaps(branches ?? []), [branches]);
|
|
902
|
+
const { collapsedSet, toggleBranch } = useBranchCollapse({
|
|
903
|
+
collapsedBranchIds,
|
|
904
|
+
defaultCollapsedBranchIds,
|
|
905
|
+
onBranchToggle,
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
// Controlled / uncontrolled selection
|
|
909
|
+
const isControlled = controlledSelectedId !== undefined;
|
|
910
|
+
const [internalSelectedId, setInternalSelectedId] = useState<string | undefined>(
|
|
911
|
+
defaultSelectedId,
|
|
912
|
+
);
|
|
913
|
+
const resolvedSelectedId = isControlled ? controlledSelectedId : internalSelectedId;
|
|
914
|
+
|
|
915
|
+
const handleSelect = useCallback(
|
|
916
|
+
(id: string) => {
|
|
917
|
+
onSelect?.(id);
|
|
918
|
+
if (!isControlled) {
|
|
919
|
+
setInternalSelectedId(id);
|
|
920
|
+
}
|
|
921
|
+
},
|
|
922
|
+
[isControlled, onSelect],
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
// Default day formatter
|
|
926
|
+
const defaultFormatDay = useCallback(
|
|
927
|
+
(d: Date) =>
|
|
928
|
+
new Intl.DateTimeFormat(undefined, {
|
|
929
|
+
weekday: "short",
|
|
930
|
+
month: "short",
|
|
931
|
+
day: "numeric",
|
|
932
|
+
year: "numeric",
|
|
933
|
+
}).format(d),
|
|
934
|
+
[],
|
|
935
|
+
);
|
|
936
|
+
const resolvedFormatDay = formatDay ?? defaultFormatDay;
|
|
937
|
+
|
|
938
|
+
// Merge detection: revision is a merge if ≥2 incoming edges target it
|
|
939
|
+
const mergeIds = useMemo(() => {
|
|
940
|
+
const incoming = new Map<string, number>();
|
|
941
|
+
for (const e of edges) {
|
|
942
|
+
incoming.set(e.to, (incoming.get(e.to) ?? 0) + 1);
|
|
943
|
+
}
|
|
944
|
+
return new Set([...incoming.entries()].filter(([, n]) => n >= 2).map(([id]) => id));
|
|
945
|
+
}, [edges]);
|
|
946
|
+
|
|
947
|
+
// Visible render items (revisions + collapsed-branch summaries). With no
|
|
948
|
+
// `branches`, `branchMaps` is empty → every item is a plain revision and
|
|
949
|
+
// `summaryOf` is empty, so the legacy render path is reproduced exactly.
|
|
950
|
+
const { items: renderItems, summaryOf } = useMemo(
|
|
951
|
+
() => computeRenderItems(revisions, collapsedSet, branchMaps),
|
|
952
|
+
[revisions, collapsedSet, branchMaps],
|
|
953
|
+
);
|
|
954
|
+
|
|
955
|
+
// Group the VISIBLE render items by day. A summary inherits its tip's
|
|
956
|
+
// timestamp, so it lands in the tip's day; a fully-hidden day drops out.
|
|
957
|
+
const groups = useMemo<Array<{ dayKey: string; dayLabel: string; items: RenderItem[] }>>(() => {
|
|
958
|
+
const itemTime = (it: RenderItem) =>
|
|
959
|
+
it.kind === "revision" ? it.revision.timestamp : it.tipTimestamp;
|
|
960
|
+
if (groupBy === "none") {
|
|
961
|
+
return [{ dayKey: "all", dayLabel: "", items: renderItems }];
|
|
962
|
+
}
|
|
963
|
+
const result: Array<{ dayKey: string; dayLabel: string; items: RenderItem[] }> = [];
|
|
964
|
+
for (const it of renderItems) {
|
|
965
|
+
const ts = itemTime(it);
|
|
966
|
+
const d = ts instanceof Date ? ts : new Date(ts);
|
|
967
|
+
// YYYY-MM-DD in local time for grouping key
|
|
968
|
+
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
969
|
+
const last = result[result.length - 1];
|
|
970
|
+
if (last && last.dayKey === key) {
|
|
971
|
+
last.items.push(it);
|
|
972
|
+
} else {
|
|
973
|
+
result.push({ dayKey: key, dayLabel: resolvedFormatDay(d), items: [it] });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return result;
|
|
977
|
+
}, [renderItems, groupBy, resolvedFormatDay]);
|
|
978
|
+
|
|
979
|
+
// Shared gutter↔list coordinate space: a per-row vertical centre keyed by
|
|
980
|
+
// render-item id (revision id OR `summary:<branchId>`), accounting for
|
|
981
|
+
// day-header height, so the SVG dots line up with their rows.
|
|
982
|
+
const ROW_H = resolvedDensity === "compact" ? ROW_H_COMPACT : ROW_H_COMFORTABLE;
|
|
983
|
+
const { rowCenterY, gutterHeight } = useMemo(() => {
|
|
984
|
+
const map = new Map<string, number>();
|
|
985
|
+
let y = 0;
|
|
986
|
+
for (const group of groups) {
|
|
987
|
+
if (groupBy === "day") y += DAY_HEADER_H;
|
|
988
|
+
for (const it of group.items) {
|
|
989
|
+
const id = it.kind === "revision" ? it.revision.id : it.id;
|
|
990
|
+
map.set(id, y + ROW_H / 2);
|
|
991
|
+
y += ROW_H;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return { rowCenterY: map, gutterHeight: y };
|
|
995
|
+
}, [groups, groupBy, ROW_H]);
|
|
996
|
+
|
|
997
|
+
// Gutter nodes: one per visible revision + one synthetic node per summary
|
|
998
|
+
// (on the collapsed branch's own lane = its tip's lane).
|
|
999
|
+
const gutterRevisions = useMemo<GutterNode[]>(
|
|
1000
|
+
() =>
|
|
1001
|
+
renderItems.map((it) =>
|
|
1002
|
+
it.kind === "revision"
|
|
1003
|
+
? { id: it.revision.id, lane: it.revision.lane }
|
|
1004
|
+
: { id: it.id, lane: it.lane },
|
|
1005
|
+
),
|
|
1006
|
+
[renderItems],
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
// Remap edges whose endpoints were hidden onto the summary that absorbed
|
|
1010
|
+
// them, so merge/fork curves still connect to the summary node.
|
|
1011
|
+
const visibleEdges = useMemo<RevisionEdge[]>(() => {
|
|
1012
|
+
if (!collapseEnabled || summaryOf.size === 0) return edges;
|
|
1013
|
+
const seen = new Set<string>();
|
|
1014
|
+
const out: RevisionEdge[] = [];
|
|
1015
|
+
for (const e of edges) {
|
|
1016
|
+
const from = summaryOf.get(e.from) ?? e.from;
|
|
1017
|
+
const to = summaryOf.get(e.to) ?? e.to;
|
|
1018
|
+
if (from === to) continue; // collapsed into a single node
|
|
1019
|
+
const key = `${from}${to}`;
|
|
1020
|
+
if (seen.has(key)) continue; // de-dup coincident remaps
|
|
1021
|
+
seen.add(key);
|
|
1022
|
+
out.push({ from, to });
|
|
1023
|
+
}
|
|
1024
|
+
return out;
|
|
1025
|
+
}, [collapseEnabled, edges, summaryOf]);
|
|
1026
|
+
|
|
1027
|
+
const ctx: RevisionTimelineCtx = useMemo(
|
|
1028
|
+
() => ({
|
|
1029
|
+
selectedId: resolvedSelectedId,
|
|
1030
|
+
onSelect: handleSelect,
|
|
1031
|
+
}),
|
|
1032
|
+
[resolvedSelectedId, handleSelect],
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
const branchLabel = (branch: RevisionBranch) => branch.name ?? branch.id;
|
|
1036
|
+
const summaryText =
|
|
1037
|
+
formatBranchSummary ?? ((count: number) => `${count} ${count === 1 ? "commit" : "commits"}`);
|
|
1038
|
+
|
|
1039
|
+
return (
|
|
1040
|
+
<RevisionTimelineContext.Provider value={ctx}>
|
|
1041
|
+
<div
|
|
1042
|
+
ref={ref}
|
|
1043
|
+
className={cn(revisionTimelineVariants({ density: resolvedDensity }), className)}
|
|
1044
|
+
{...props}
|
|
1045
|
+
>
|
|
1046
|
+
{/* Lane gutter + list side-by-side */}
|
|
1047
|
+
<div className="flex min-w-0">
|
|
1048
|
+
{/* SVG branch-graph gutter */}
|
|
1049
|
+
<LaneGutter
|
|
1050
|
+
revisions={gutterRevisions}
|
|
1051
|
+
edges={visibleEdges}
|
|
1052
|
+
rowCenterY={rowCenterY}
|
|
1053
|
+
height={gutterHeight}
|
|
1054
|
+
className="mr-1"
|
|
1055
|
+
/>
|
|
1056
|
+
|
|
1057
|
+
{/* Revision list */}
|
|
1058
|
+
<div className="min-w-0 flex-1">
|
|
1059
|
+
{groups.map((group) => (
|
|
1060
|
+
<div key={group.dayKey}>
|
|
1061
|
+
{groupBy === "day" && <DayHeader label={group.dayLabel} />}
|
|
1062
|
+
<ol role="list" aria-label={groupBy === "day" ? group.dayLabel : "Revisions"}>
|
|
1063
|
+
{group.items.map((it) => {
|
|
1064
|
+
if (it.kind === "summary") {
|
|
1065
|
+
return (
|
|
1066
|
+
<CollapsedBranchRow
|
|
1067
|
+
key={it.id}
|
|
1068
|
+
branchId={it.branch.id}
|
|
1069
|
+
branchName={branchLabel(it.branch)}
|
|
1070
|
+
summaryLabel={summaryText(it.count, it.branch)}
|
|
1071
|
+
density={resolvedDensity}
|
|
1072
|
+
indent={it.lane * LANE_W}
|
|
1073
|
+
onExpand={() => toggleBranch(it.branch.id)}
|
|
1074
|
+
/>
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
const branch = it.branchId
|
|
1078
|
+
? branchMaps.branchById.get(it.branchId)
|
|
1079
|
+
: undefined;
|
|
1080
|
+
const collapseToggle =
|
|
1081
|
+
it.canCollapse && branch
|
|
1082
|
+
? {
|
|
1083
|
+
branchName: branchLabel(branch),
|
|
1084
|
+
onToggle: () => toggleBranch(branch.id),
|
|
1085
|
+
}
|
|
1086
|
+
: undefined;
|
|
1087
|
+
return (
|
|
1088
|
+
<RevisionRow
|
|
1089
|
+
key={it.revision.id}
|
|
1090
|
+
revision={it.revision}
|
|
1091
|
+
isMerge={mergeIds.has(it.revision.id)}
|
|
1092
|
+
density={resolvedDensity}
|
|
1093
|
+
indent={it.revision.lane * LANE_W}
|
|
1094
|
+
collapseToggle={collapseToggle}
|
|
1095
|
+
/>
|
|
1096
|
+
);
|
|
1097
|
+
})}
|
|
1098
|
+
</ol>
|
|
1099
|
+
</div>
|
|
1100
|
+
))}
|
|
1101
|
+
</div>
|
|
1102
|
+
</div>
|
|
1103
|
+
</div>
|
|
1104
|
+
</RevisionTimelineContext.Provider>
|
|
1105
|
+
);
|
|
1106
|
+
},
|
|
1107
|
+
);
|