11// Copyright 2025, Command Line Inc.
22// SPDX-License-Identifier: Apache-2.0
33
4- import { BuilderAppPanelModel , type TabType } from "@/builder/store/builderAppPanelModel " ;
5- import { BuilderFocusManager } from "@/builder/store/builderFocusManager " ;
4+ import { BuilderAppPanelModel , type TabType } from "@/builder/store/builder-apppanel-model " ;
5+ import { BuilderFocusManager } from "@/builder/store/builder-focusmanager " ;
66import { BuilderCodeTab } from "@/builder/tabs/builder-codetab" ;
7+ import { BuilderEnvTab } from "@/builder/tabs/builder-envtab" ;
78import { BuilderFilesTab } from "@/builder/tabs/builder-filestab" ;
89import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab" ;
910import { builderAppHasSelection } from "@/builder/utils/builder-focus-utils" ;
1011import { ErrorBoundary } from "@/element/errorboundary" ;
1112import { atoms } from "@/store/global" ;
1213import { cn } from "@/util/util" ;
1314import { useAtomValue } from "jotai" ;
14- import { memo , useCallback , useRef } from "react" ;
15+ import { memo , useCallback , useEffect , useRef } from "react" ;
16+
17+ const StatusDot = memo ( ( ) => {
18+ const model = BuilderAppPanelModel . getInstance ( ) ;
19+ const builderStatus = useAtomValue ( model . builderStatusAtom ) ;
20+
21+ const getStatusDotColor = ( status : string | null | undefined ) : string => {
22+ if ( ! status ) return "bg-gray-500" ;
23+ switch ( status ) {
24+ case "init" :
25+ case "stopped" :
26+ return "bg-gray-500" ;
27+ case "building" :
28+ return "bg-warning" ;
29+ case "running" :
30+ return "bg-success" ;
31+ case "error" :
32+ return "bg-error" ;
33+ default :
34+ return "bg-gray-500" ;
35+ }
36+ } ;
37+
38+ const statusDotColor = getStatusDotColor ( builderStatus ?. status ) ;
39+
40+ return < span className = { cn ( "w-2 h-2 rounded-full" , statusDotColor ) } /> ;
41+ } ) ;
42+
43+ StatusDot . displayName = "StatusDot" ;
1544
1645type TabButtonProps = {
1746 label : string ;
1847 tabType : TabType ;
1948 isActive : boolean ;
2049 isAppFocused : boolean ;
2150 onClick : ( ) => void ;
51+ showStatusDot ?: boolean ;
2252} ;
2353
24- const TabButton = memo ( ( { label, tabType, isActive, isAppFocused, onClick } : TabButtonProps ) => {
54+ const TabButton = memo ( ( { label, tabType, isActive, isAppFocused, onClick, showStatusDot } : TabButtonProps ) => {
2555 return (
2656 < button
2757 className = { cn (
2858 "px-4 py-2 text-sm font-medium transition-colors cursor-pointer" ,
2959 isActive
30- ? `text-main-text border-b-2 ${ isAppFocused ? "border-accent" : "border-gray-500" } `
31- : "text-gray-500 hover:text-secondary border-b-2 border-transparent"
60+ ? `text-primary border-b-2 ${ isAppFocused ? "border-accent" : "border-gray-500" } `
61+ : "text-secondary hover:text-primary border-b-2 border-transparent"
3262 ) }
3363 onClick = { onClick }
3464 >
35- { label }
65+ < span className = "flex items-center gap-2" >
66+ { showStatusDot && < StatusDot /> }
67+ { label }
68+ </ span >
3669 </ button >
3770 ) ;
3871} ) ;
3972
4073TabButton . displayName = "TabButton" ;
4174
75+ const ErrorStrip = memo ( ( ) => {
76+ const model = BuilderAppPanelModel . getInstance ( ) ;
77+ const errorMsg = useAtomValue ( model . errorAtom ) ;
78+
79+ if ( ! errorMsg ) return null ;
80+ return (
81+ < div className = "shrink-0 bg-error/10 border-b border-error/30 px-4 py-2 flex items-center justify-between gap-4" >
82+ < div className = "flex items-center gap-3 flex-1 min-w-0" >
83+ < i className = "fa fa-triangle-exclamation text-error text-sm" />
84+ < span className = "text-error text-sm flex-1 truncate" > { errorMsg } </ span >
85+ </ div >
86+ < button
87+ onClick = { ( ) => model . clearError ( ) }
88+ className = "shrink-0 text-error hover:text-error/80 transition-colors cursor-pointer"
89+ aria-label = "Close error"
90+ >
91+ < i className = "fa fa-xmark-large text-sm" />
92+ </ button >
93+ </ div >
94+ ) ;
95+ } ) ;
96+
97+ ErrorStrip . displayName = "ErrorStrip" ;
98+
4299const BuilderAppPanel = memo ( ( ) => {
43100 const model = BuilderAppPanelModel . getInstance ( ) ;
44101 const focusElemRef = useRef < HTMLInputElement > ( null ) ;
45102 const activeTab = useAtomValue ( model . activeTab ) ;
46103 const focusType = useAtomValue ( BuilderFocusManager . getInstance ( ) . focusType ) ;
47104 const isAppFocused = focusType === "app" ;
48105 const saveNeeded = useAtomValue ( model . saveNeededAtom ) ;
106+ const envSaveNeeded = useAtomValue ( model . envVarsDirtyAtom ) ;
49107 const builderAppId = useAtomValue ( atoms . builderAppId ) ;
108+ const builderId = useAtomValue ( atoms . builderId ) ;
109+
110+ useEffect ( ( ) => {
111+ model . initialize ( ) ;
112+ } , [ ] ) ;
50113
51114 if ( focusElemRef . current ) {
52115 model . setFocusElemRef ( focusElemRef . current ) ;
@@ -58,44 +121,54 @@ const BuilderAppPanel = memo(() => {
58121 model . giveFocus ( ) ;
59122 } ;
60123
61- const handleFocusCapture = useCallback (
62- ( event : React . FocusEvent ) => {
63- BuilderFocusManager . getInstance ( ) . setAppFocused ( ) ;
64- } ,
65- [ ]
66- ) ;
67-
68- const handlePanelClick = useCallback ( ( e : React . MouseEvent ) => {
69- const target = e . target as HTMLElement ;
70- const isInteractive = target . closest ( 'button, a, input, textarea, select, [role="button"], [tabindex]' ) ;
124+ const handleFocusCapture = useCallback ( ( event : React . FocusEvent ) => {
125+ BuilderFocusManager . getInstance ( ) . setAppFocused ( ) ;
126+ } , [ ] ) ;
71127
72- if ( isInteractive ) {
73- return ;
74- }
128+ const handlePanelClick = useCallback (
129+ ( e : React . MouseEvent ) => {
130+ const target = e . target as HTMLElement ;
131+ const isInteractive = target . closest ( 'button, a, input, textarea, select, [role="button"], [tabindex]' ) ;
75132
76- const hasSelection = builderAppHasSelection ( ) ;
77- if ( hasSelection ) {
78- BuilderFocusManager . getInstance ( ) . setAppFocused ( ) ;
79- return ;
80- }
133+ if ( isInteractive ) {
134+ return ;
135+ }
81136
82- setTimeout ( ( ) => {
83- if ( ! builderAppHasSelection ( ) ) {
137+ const hasSelection = builderAppHasSelection ( ) ;
138+ if ( hasSelection ) {
84139 BuilderFocusManager . getInstance ( ) . setAppFocused ( ) ;
85- model . giveFocus ( ) ;
140+ return ;
86141 }
87- } , 0 ) ;
88- } , [ model ] ) ;
142+
143+ setTimeout ( ( ) => {
144+ if ( ! builderAppHasSelection ( ) ) {
145+ BuilderFocusManager . getInstance ( ) . setAppFocused ( ) ;
146+ model . giveFocus ( ) ;
147+ }
148+ } , 0 ) ;
149+ } ,
150+ [ model ]
151+ ) ;
89152
90153 const handleSave = useCallback ( ( ) => {
91154 if ( builderAppId ) {
92155 model . saveAppFile ( builderAppId ) ;
93156 }
94157 } , [ builderAppId , model ] ) ;
95158
159+ const handleEnvSave = useCallback ( ( ) => {
160+ if ( builderId ) {
161+ model . saveEnvVars ( builderId ) ;
162+ }
163+ } , [ builderId , model ] ) ;
164+
165+ const handleRestart = useCallback ( ( ) => {
166+ model . restartBuilder ( ) ;
167+ } , [ model ] ) ;
168+
96169 return (
97170 < div
98- className = "w-full h-full flex flex-col border-b border-border"
171+ className = "w-full h-full flex flex-col border-b-3 border-border shadow-[0_2px_4px_rgba(0,0,0,0.1)] "
99172 data-builder-app-panel = "true"
100173 onClick = { handlePanelClick }
101174 onFocusCapture = { handleFocusCapture }
@@ -118,6 +191,7 @@ const BuilderAppPanel = memo(() => {
118191 isActive = { activeTab === "preview" }
119192 isAppFocused = { isAppFocused }
120193 onClick = { ( ) => handleTabClick ( "preview" ) }
194+ showStatusDot = { true }
121195 />
122196 < TabButton
123197 label = "Code"
@@ -126,14 +200,31 @@ const BuilderAppPanel = memo(() => {
126200 isAppFocused = { isAppFocused }
127201 onClick = { ( ) => handleTabClick ( "code" ) }
128202 />
203+ { false && (
204+ < TabButton
205+ label = "Static Files"
206+ tabType = "files"
207+ isActive = { activeTab === "files" }
208+ isAppFocused = { isAppFocused }
209+ onClick = { ( ) => handleTabClick ( "files" ) }
210+ />
211+ ) }
129212 < TabButton
130- label = "Static Files "
131- tabType = "files "
132- isActive = { activeTab === "files " }
213+ label = "Env "
214+ tabType = "env "
215+ isActive = { activeTab === "env " }
133216 isAppFocused = { isAppFocused }
134- onClick = { ( ) => handleTabClick ( "files " ) }
217+ onClick = { ( ) => handleTabClick ( "env " ) }
135218 />
136219 </ div >
220+ { activeTab === "preview" && (
221+ < button
222+ className = "mr-4 px-3 py-1 text-sm font-medium rounded transition-colors bg-accent/80 text-white hover:bg-accent cursor-pointer"
223+ onClick = { handleRestart }
224+ >
225+ Restart App
226+ </ button >
227+ ) }
137228 { activeTab === "code" && (
138229 < button
139230 className = { cn (
@@ -147,8 +238,22 @@ const BuilderAppPanel = memo(() => {
147238 Save
148239 </ button >
149240 ) }
241+ { activeTab === "env" && (
242+ < button
243+ className = { cn (
244+ "mr-4 px-3 py-1 text-sm font-medium rounded transition-colors" ,
245+ envSaveNeeded
246+ ? "bg-accent text-white hover:opacity-80 cursor-pointer"
247+ : "bg-gray-600 text-gray-400 cursor-default"
248+ ) }
249+ onClick = { envSaveNeeded ? handleEnvSave : undefined }
250+ >
251+ Save
252+ </ button >
253+ ) }
150254 </ div >
151255 </ div >
256+ < ErrorStrip />
152257 < div className = "flex-1 overflow-auto py-1" >
153258 < div className = "w-full h-full" style = { { display : activeTab === "preview" ? "block" : "none" } } >
154259 < ErrorBoundary >
@@ -165,6 +270,11 @@ const BuilderAppPanel = memo(() => {
165270 < BuilderFilesTab />
166271 </ ErrorBoundary >
167272 </ div >
273+ < div className = "w-full h-full" style = { { display : activeTab === "env" ? "block" : "none" } } >
274+ < ErrorBoundary >
275+ < BuilderEnvTab />
276+ </ ErrorBoundary >
277+ </ div >
168278 </ div >
169279 </ div >
170280 ) ;
0 commit comments