@@ -22,7 +22,7 @@ namespace Coder.Desktop.App.ViewModels;
2222
2323public interface IAgentViewModelFactory
2424{
25- public AgentViewModel Create ( Uuid id , string hostname , string hostnameSuffix ,
25+ public AgentViewModel Create ( IAgentExpanderHost expanderHost , Uuid id , string hostname , string hostnameSuffix ,
2626 AgentConnectionStatus connectionStatus , Uri dashboardBaseUrl , string ? workspaceName ) ;
2727}
2828
@@ -32,12 +32,12 @@ public class AgentViewModelFactory(
3232 ICredentialManager credentialManager ,
3333 IAgentAppViewModelFactory agentAppViewModelFactory ) : IAgentViewModelFactory
3434{
35- public AgentViewModel Create ( Uuid id , string hostname , string hostnameSuffix ,
35+ public AgentViewModel Create ( IAgentExpanderHost expanderHost , Uuid id , string hostname , string hostnameSuffix ,
3636 AgentConnectionStatus connectionStatus , Uri dashboardBaseUrl , string ? workspaceName )
3737 {
38- return new AgentViewModel ( childLogger , coderApiClientFactory , credentialManager , agentAppViewModelFactory )
38+ return new AgentViewModel ( childLogger , coderApiClientFactory , credentialManager , agentAppViewModelFactory ,
39+ expanderHost , id )
3940 {
40- Id = id ,
4141 Hostname = hostname ,
4242 HostnameSuffix = hostnameSuffix ,
4343 ConnectionStatus = connectionStatus ,
@@ -74,12 +74,14 @@ public partial class AgentViewModel : ObservableObject, IModelUpdateable<AgentVi
7474 private readonly DispatcherQueue _dispatcherQueue =
7575 DispatcherQueue . GetForCurrentThread ( ) ;
7676
77+ private readonly IAgentExpanderHost _expanderHost ;
78+
7779 // This isn't an ObservableProperty because the property itself never
7880 // changes. We add an event listener for the collection changing in the
7981 // constructor.
8082 public readonly ObservableCollection < AgentAppViewModel > Apps = [ ] ;
8183
82- public required Uuid Id { get ; init ; }
84+ public readonly Uuid Id ;
8385
8486 [ ObservableProperty ]
8587 [ NotifyPropertyChangedFor ( nameof ( FullHostname ) ) ]
@@ -160,12 +162,28 @@ public string DashboardUrl
160162 }
161163
162164 public AgentViewModel ( ILogger < AgentViewModel > logger , ICoderApiClientFactory coderApiClientFactory ,
163- ICredentialManager credentialManager , IAgentAppViewModelFactory agentAppViewModelFactory )
165+ ICredentialManager credentialManager , IAgentAppViewModelFactory agentAppViewModelFactory ,
166+ IAgentExpanderHost expanderHost , Uuid id )
164167 {
165168 _logger = logger ;
166169 _coderApiClientFactory = coderApiClientFactory ;
167170 _credentialManager = credentialManager ;
168171 _agentAppViewModelFactory = agentAppViewModelFactory ;
172+ _expanderHost = expanderHost ;
173+
174+ Id = id ;
175+
176+ PropertyChanged += ( _ , args ) =>
177+ {
178+ if ( args . PropertyName == nameof ( IsExpanded ) )
179+ {
180+ _expanderHost . HandleAgentExpanded ( Id , IsExpanded ) ;
181+
182+ // Every time the drawer is expanded, re-fetch all apps.
183+ if ( IsExpanded && ! FetchingApps )
184+ FetchApps ( ) ;
185+ }
186+ } ;
169187
170188 // Since the property value itself never changes, we add event
171189 // listeners for the underlying collection changing instead.
@@ -202,18 +220,15 @@ public bool TryApplyChanges(AgentViewModel model)
202220 [ RelayCommand ]
203221 private void ToggleExpanded ( )
204222 {
205- // TODO: this should bubble to every other agent in the list so only
206- // one can be active at a time.
207223 SetExpanded ( ! IsExpanded ) ;
208224 }
209225
210226 public void SetExpanded ( bool expanded )
211227 {
228+ if ( IsExpanded == expanded ) return ;
229+ // This will bubble up to the TrayWindowViewModel because of the
230+ // PropertyChanged handler.
212231 IsExpanded = expanded ;
213-
214- // Every time the drawer is expanded, re-fetch all apps.
215- if ( expanded && ! FetchingApps )
216- FetchApps ( ) ;
217232 }
218233
219234 partial void OnConnectionStatusChanged ( AgentConnectionStatus oldValue , AgentConnectionStatus newValue )
@@ -226,7 +241,26 @@ private void FetchApps()
226241 if ( FetchingApps ) return ;
227242 FetchingApps = true ;
228243
229- var client = _coderApiClientFactory . Create ( _credentialManager ) ;
244+ // If the workspace is off, then there's no agent and there's no apps.
245+ if ( ConnectionStatus == AgentConnectionStatus . Gray )
246+ {
247+ FetchingApps = false ;
248+ Apps . Clear ( ) ;
249+ return ;
250+ }
251+
252+ // API client creation could fail, which would leave FetchingApps true.
253+ ICoderApiClient client ;
254+ try
255+ {
256+ client = _coderApiClientFactory . Create ( _credentialManager ) ;
257+ }
258+ catch
259+ {
260+ FetchingApps = false ;
261+ throw ;
262+ }
263+
230264 var cts = new CancellationTokenSource ( TimeSpan . FromSeconds ( 15 ) ) ;
231265 client . GetWorkspaceAgent ( Id . ToString ( ) , cts . Token ) . ContinueWith ( t =>
232266 {
@@ -265,18 +299,24 @@ private void ContinueFetchApps(Task<WorkspaceAgent> task)
265299 continue ;
266300 }
267301
268- if ( string . IsNullOrEmpty ( app . Url ) )
302+ if ( ! Uri . TryCreate ( app . Url , UriKind . Absolute , out var appUri ) )
269303 {
270- _logger . LogWarning ( "App URI '{Url}' for '{DisplayName}' is empty, app will not appear in list" , app . Url ,
304+ _logger . LogWarning ( "Could not parse app URI '{Url}' for '{DisplayName}', app will not appear in list" ,
305+ app . Url ,
271306 app . DisplayName ) ;
272307 continue ;
273308 }
274309
310+ // HTTP or HTTPS external apps are usually things like
311+ // wikis/documentation, which clutters up the app.
312+ if ( appUri . Scheme is "http" or "https" )
313+ continue ;
314+
275315 // Icon parse failures are not fatal, we will just use the fallback
276316 // icon.
277317 _ = Uri . TryCreate ( DashboardBaseUrl , app . Icon , out var iconUrl ) ;
278318
279- apps . Add ( _agentAppViewModelFactory . Create ( uuid , app . DisplayName , app . Url , iconUrl ) ) ;
319+ apps . Add ( _agentAppViewModelFactory . Create ( uuid , app . DisplayName , appUri , iconUrl ) ) ;
280320 }
281321
282322 foreach ( var displayApp in workspaceAgent . DisplayApps )
@@ -296,7 +336,22 @@ private void ContinueFetchApps(Task<WorkspaceAgent> task)
296336 scheme = "vscode-insiders" ;
297337 }
298338
299- var appUri = $ "{ scheme } ://vscode-remote/ssh-remote+{ FullHostname } /{ workspaceAgent . ExpandedDirectory } ";
339+ Uri appUri ;
340+ try
341+ {
342+ appUri = new UriBuilder
343+ {
344+ Scheme = scheme ,
345+ Host = "vscode-remote" ,
346+ Path = $ "/ssh-remote+{ FullHostname } /{ workspaceAgent . ExpandedDirectory } ",
347+ } . Uri ;
348+ }
349+ catch ( Exception e )
350+ {
351+ _logger . LogWarning ( "Could not craft app URI for display app {displayApp}, app will not appear in list" ,
352+ displayApp ) ;
353+ continue ;
354+ }
300355
301356 // Icon parse failures are not fatal, we will just use the fallback
302357 // icon.
0 commit comments