1// Copyright (C) 2024 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16 17import {Gate} from '../base/mithril_utils'; 18import {Actions} from '../common/actions'; 19import {getLegacySelection} from '../common/state'; 20import {EmptyState} from '../widgets/empty_state'; 21 22import { 23 DragHandle, 24 Tab, 25 TabDropdownEntry, 26 getDefaultDetailsHeight, 27} from './drag_handle'; 28import {globals} from './globals'; 29import {raf} from '../core/raf_scheduler'; 30 31interface TabWithContent extends Tab { 32 content: m.Children; 33} 34 35export class TabPanel implements m.ClassComponent { 36 // Tabs panel starts collapsed. 37 private detailsHeight = 0; 38 private fadeContext = new FadeContext(); 39 private hasBeenDragged = false; 40 41 view() { 42 const tabMan = globals.tabManager; 43 const tabList = globals.store.state.tabs.openTabs; 44 45 const resolvedTabs = tabMan.resolveTabs(tabList); 46 const tabs = resolvedTabs.map(({uri, tab: tabDesc}): TabWithContent => { 47 if (tabDesc) { 48 return { 49 key: uri, 50 hasCloseButton: true, 51 title: tabDesc.content.getTitle(), 52 content: tabDesc.content.render(), 53 }; 54 } else { 55 return { 56 key: uri, 57 hasCloseButton: true, 58 title: 'Tab does not exist', 59 content: undefined, 60 }; 61 } 62 }); 63 64 if ( 65 !this.hasBeenDragged && 66 (tabs.length > 0 || globals.state.selection.kind !== 'empty') 67 ) { 68 this.detailsHeight = getDefaultDetailsHeight(); 69 } 70 71 // Add the permanent current selection tab to the front of the list of tabs 72 tabs.unshift({ 73 key: 'current_selection', 74 title: 'Current Selection', 75 content: this.renderCSTabContentWithFading(), 76 }); 77 78 const tabDropdownEntries = globals.tabManager.tabs 79 .filter((tab) => tab.isEphemeral === false) 80 .map(({content, uri}): TabDropdownEntry => { 81 // Check if the tab is already open 82 const isOpen = globals.state.tabs.openTabs.find((openTabUri) => { 83 return openTabUri === uri; 84 }); 85 const clickAction = isOpen 86 ? Actions.hideTab({uri}) 87 : Actions.showTab({uri}); 88 return { 89 key: uri, 90 title: content.getTitle(), 91 onClick: () => globals.dispatch(clickAction), 92 checked: isOpen !== undefined, 93 }; 94 }); 95 96 return [ 97 m(DragHandle, { 98 resize: (height: number) => { 99 this.detailsHeight = Math.max(height, 0); 100 this.hasBeenDragged = true; 101 }, 102 height: this.detailsHeight, 103 tabs, 104 currentTabKey: globals.state.tabs.currentTab, 105 tabDropdownEntries, 106 onTabClick: (key) => globals.dispatch(Actions.showTab({uri: key})), 107 onTabClose: (key) => globals.dispatch(Actions.hideTab({uri: key})), 108 }), 109 m( 110 '.details-panel-container', 111 { 112 style: {height: `${this.detailsHeight}px`}, 113 }, 114 tabs.map(({key, content}) => { 115 const active = key === globals.state.tabs.currentTab; 116 return m(Gate, {open: active}, content); 117 }), 118 ), 119 ]; 120 } 121 122 private renderCSTabContentWithFading(): m.Children { 123 const section = this.renderCSTabContent(); 124 if (section.isLoading) { 125 return m(FadeIn, section.content); 126 } else { 127 return m(FadeOut, {context: this.fadeContext}, section.content); 128 } 129 } 130 131 private renderCSTabContent(): {isLoading: boolean; content: m.Children} { 132 const currentSelection = globals.state.selection; 133 const legacySelection = getLegacySelection(globals.state); 134 if (currentSelection.kind === 'empty') { 135 return { 136 isLoading: false, 137 content: m( 138 EmptyState, 139 { 140 className: 'pf-noselection', 141 title: 'Nothing selected', 142 }, 143 'Selection details will appear here', 144 ), 145 }; 146 } 147 148 // Show single selection panels if they are registered 149 if (currentSelection.kind === 'single') { 150 const trackKey = currentSelection.trackKey; 151 const uri = globals.state.tracks[trackKey]?.uri; 152 153 if (uri) { 154 const trackDesc = globals.trackManager.resolveTrackInfo(uri); 155 const panel = trackDesc?.detailsPanel; 156 if (panel) { 157 return { 158 content: panel.render(currentSelection.eventId), 159 isLoading: panel.isLoading?.() ?? false, 160 }; 161 } 162 } 163 } 164 165 // Get the first "truthy" details panel 166 let detailsPanels = globals.tabManager.detailsPanels.map((dp) => { 167 return { 168 content: dp.render(currentSelection), 169 isLoading: dp.isLoading?.() ?? false, 170 }; 171 }); 172 173 if (legacySelection !== null) { 174 const legacyDetailsPanels = globals.tabManager.legacyDetailsPanels.map( 175 (dp) => { 176 return { 177 content: dp.render(legacySelection), 178 isLoading: dp.isLoading?.() ?? false, 179 }; 180 }, 181 ); 182 183 detailsPanels = detailsPanels.concat(legacyDetailsPanels); 184 } 185 186 const panel = detailsPanels.find(({content}) => content); 187 188 if (panel) { 189 return panel; 190 } else { 191 return { 192 isLoading: false, 193 content: m( 194 EmptyState, 195 { 196 className: 'pf-noselection', 197 title: 'No details available', 198 icon: 'warning', 199 }, 200 `Selection kind: '${currentSelection.kind}'`, 201 ), 202 }; 203 } 204 } 205} 206 207const FADE_TIME_MS = 50; 208 209class FadeContext { 210 private resolver = () => {}; 211 212 putResolver(res: () => void) { 213 this.resolver = res; 214 } 215 216 resolve() { 217 this.resolver(); 218 this.resolver = () => {}; 219 } 220} 221 222interface FadeOutAttrs { 223 context: FadeContext; 224} 225 226class FadeOut implements m.ClassComponent<FadeOutAttrs> { 227 onbeforeremove({attrs}: m.VnodeDOM<FadeOutAttrs>): Promise<void> { 228 return new Promise((res) => { 229 attrs.context.putResolver(res); 230 setTimeout(res, FADE_TIME_MS); 231 }); 232 } 233 234 oncreate({attrs}: m.VnodeDOM<FadeOutAttrs>) { 235 attrs.context.resolve(); 236 } 237 238 view(vnode: m.Vnode<FadeOutAttrs>): void | m.Children { 239 return vnode.children; 240 } 241} 242 243class FadeIn implements m.ClassComponent { 244 private show = false; 245 246 oncreate(_: m.VnodeDOM) { 247 setTimeout(() => { 248 this.show = true; 249 raf.scheduleFullRedraw(); 250 }, FADE_TIME_MS); 251 } 252 253 view(vnode: m.Vnode): m.Children { 254 return this.show ? vnode.children : undefined; 255 } 256} 257