11import * as vscode from "vscode" ;
22
33import { errToStr } from "../api/api-helper" ;
4- import { type CoderApi } from "../api/coderApi" ;
5- import { type SecretsManager } from "../core/secretsManager" ;
6- import { type SessionState } from "../deployment/sessionStore" ;
7- import { type Logger } from "../logging/logger" ;
84import { areNotificationsDisabled } from "../settings/notifications" ;
5+ import { createStatusBarItem } from "../util/statusBar" ;
96
107import {
118 type Announcement ,
@@ -15,7 +12,12 @@ import {
1512 statusTooltip ,
1613} from "./banners" ;
1714
18- const REFRESH_INTERVAL_MS = 30 * 60 * 1000 ;
15+ import type { CoderApi } from "../api/coderApi" ;
16+ import type { SecretsManager } from "../core/secretsManager" ;
17+ import type { SessionState } from "../deployment/sessionStore" ;
18+ import type { Logger } from "../logging/logger" ;
19+
20+ export const REFRESH_INTERVAL_MS = 30 * 60 * 1000 ;
1921const VIEW_ACTION = "View" ;
2022
2123interface RefreshOptions {
@@ -24,9 +26,10 @@ interface RefreshOptions {
2426}
2527
2628export class AnnouncementManager implements vscode . Disposable {
27- private readonly statusBarItem : vscode . StatusBarItem ;
29+ private readonly statusBarItem = createStatusBarItem ( "announcements" ) ;
2830 private readonly sessionChangeDisposable : vscode . Disposable ;
29- private banners : readonly Announcement [ ] = [ ] ;
31+ #banners: readonly Announcement [ ] = [ ] ;
32+ private fetchGeneration = 0 ;
3033 private refreshTimeout : NodeJS . Timeout | undefined ;
3134 private disposed = false ;
3235
@@ -36,119 +39,112 @@ export class AnnouncementManager implements vscode.Disposable {
3639 private readonly secretsManager : SecretsManager ,
3740 private readonly logger : Logger ,
3841 ) {
39- this . statusBarItem = vscode . window . createStatusBarItem (
40- vscode . StatusBarAlignment . Left ,
41- 998 ,
42- ) ;
43- this . statusBarItem . name = "Coder Announcements" ;
4442 this . statusBarItem . command = "coder.viewAnnouncements" ;
4543 this . sessionChangeDisposable = this . sessionState . onDidChange ( ( ) => {
4644 this . onSessionChange ( ) ;
4745 } ) ;
4846 this . onSessionChange ( ) ;
4947 }
5048
51- public dispose ( ) : void {
52- this . disposed = true ;
53- this . cancelRefresh ( ) ;
54- this . sessionChangeDisposable . dispose ( ) ;
55- this . statusBarItem . dispose ( ) ;
56- }
57-
58- public async refresh (
59- options : RefreshOptions = { } ,
60- ) : Promise < readonly Announcement [ ] | undefined > {
49+ /** Refreshes the banners; resolves false when the refresh failed. */
50+ public async refresh ( options : RefreshOptions = { } ) : Promise < boolean > {
6151 if ( this . disposed ) {
62- return undefined ;
52+ return false ;
6353 }
6454 this . cancelRefresh ( ) ;
6555 try {
66- return await this . fetch ( options ) ;
56+ await this . fetch ( options ) ;
57+ return true ;
6758 } catch ( error ) {
6859 this . logger . warn ( "Failed to refresh Coder announcements" , error ) ;
6960 if ( options . showErrors ) {
7061 void vscode . window . showErrorMessage (
7162 `Failed to refresh Coder announcements: ${ errToStr ( error ) } ` ,
7263 ) ;
7364 }
74- return undefined ;
65+ return false ;
7566 } finally {
7667 this . scheduleRefresh ( ) ;
7768 }
7869 }
7970
8071 public async showAnnouncements ( ) : Promise < void > {
81- const banners =
82- ( await this . refresh ( { notify : false , showErrors : true } ) ) ?? this . banners ;
83- if ( banners . length === 0 ) {
72+ const refreshed = await this . refresh ( { notify : false , showErrors : true } ) ;
73+ if ( this . banners . length > 0 ) {
74+ await this . pickAnnouncement ( ) ;
75+ } else if ( refreshed ) {
76+ // On failure the error popup is the whole story.
8477 void vscode . window . showInformationMessage (
8578 "No active Coder announcements." ,
8679 ) ;
87- return ;
8880 }
81+ }
8982
90- const selected = await vscode . window . showQuickPick (
91- banners . map ( ( banner , index ) => ( {
92- label : `${ banner . source === "service" ? "$(info) Service banner" : "$(megaphone) Announcement" } ${ index + 1 } ` ,
93- detail : banner . message ,
94- banner,
95- } ) ) ,
96- {
97- title : "Coder Announcements" ,
98- placeHolder : "Select an announcement to view the full message" ,
99- } ,
100- ) ;
101- if ( selected ) {
102- void vscode . window . showInformationMessage ( selected . banner . message ) ;
103- }
83+ public dispose ( ) : void {
84+ this . disposed = true ;
85+ this . cancelRefresh ( ) ;
86+ this . sessionChangeDisposable . dispose ( ) ;
87+ this . banners = [ ] ;
88+ this . statusBarItem . dispose ( ) ;
10489 }
10590
10691 private onSessionChange ( ) : void {
10792 this . cancelRefresh ( ) ;
108- this . setBanners ( [ ] ) ;
93+ this . banners = [ ] ;
10994 if ( this . sessionState . current . kind === "signedIn" ) {
11095 void this . refresh ( { notify : true } ) ;
11196 }
11297 }
11398
114- private async fetch (
115- options : RefreshOptions ,
116- ) : Promise < readonly Announcement [ ] | undefined > {
99+ private async fetch ( options : RefreshOptions ) : Promise < void > {
117100 const session = this . sessionState . current ;
118101 if ( session . kind !== "signedIn" ) {
119- this . setBanners ( [ ] ) ;
120- return [ ] ;
102+ this . banners = [ ] ;
103+ return ;
121104 }
122105
123- const banners = normalizeBanners ( await this . client . getAppearance ( ) ) ;
124- if ( this . disposed || this . sessionState . current !== session ) {
125- return undefined ;
106+ const generation = ++ this . fetchGeneration ;
107+ const appearance = await this . client . getAppearance ( ) ;
108+ // A newer fetch or a session change supersedes this response.
109+ if (
110+ this . disposed ||
111+ generation !== this . fetchGeneration ||
112+ this . sessionState . current !== session
113+ ) {
114+ return ;
126115 }
127- this . setBanners ( banners ) ;
116+ const banners = normalizeBanners ( appearance ) ;
117+ this . banners = banners ;
128118
129119 const seen = new Set (
130120 this . secretsManager . getSeenBanners ( session . deployment . safeHostname ) ,
131121 ) ;
132- const keys = banners . map ( ( banner ) => banner . key ) ;
133122 const unseen = banners . filter ( ( banner ) => ! seen . has ( banner . key ) ) ;
134- if (
135- options . notify &&
136- unseen . length > 0 &&
137- ! areNotificationsDisabled ( vscode . workspace . getConfiguration ( ) )
138- ) {
139- void this . showPopup ( unseen ) ;
123+ if ( unseen . length === 0 ) {
124+ return ;
140125 }
141- if ( unseen . length > 0 || seen . size !== new Set ( keys ) . size ) {
126+ const notifiable = ! areNotificationsDisabled (
127+ vscode . workspace . getConfiguration ( ) ,
128+ ) ;
129+ if ( options . notify && notifiable ) {
130+ this . showPopup ( unseen ) ;
131+ }
132+ // Mark seen only what the user could see; suppressed banners notify later.
133+ if ( ! options . notify || notifiable ) {
142134 await this . secretsManager . setSeenBanners (
143135 session . deployment . safeHostname ,
144- keys ,
136+ [ ... seen , ... unseen . map ( ( banner ) => banner . key ) ] ,
145137 ) ;
146138 }
147- return banners ;
148139 }
149140
150- private setBanners ( banners : readonly Announcement [ ] ) : void {
151- this . banners = banners ;
141+ /** The setter syncs the status bar so the two cannot drift apart. */
142+ private get banners ( ) : readonly Announcement [ ] {
143+ return this . #banners;
144+ }
145+
146+ private set banners ( banners : readonly Announcement [ ] ) {
147+ this . #banners = banners ;
152148 if ( banners . length === 0 ) {
153149 this . statusBarItem . hide ( ) ;
154150 return ;
@@ -158,17 +154,34 @@ export class AnnouncementManager implements vscode.Disposable {
158154 this . statusBarItem . show ( ) ;
159155 }
160156
161- private async showPopup ( banners : readonly Announcement [ ] ) : Promise < void > {
162- try {
163- const action = await vscode . window . showInformationMessage (
164- popupMessage ( banners ) ,
165- VIEW_ACTION ,
166- ) ;
167- if ( action === VIEW_ACTION ) {
168- void this . showAnnouncements ( ) ;
169- }
170- } catch ( error ) {
171- this . logger . warn ( "Failed to show Coder announcement popup" , error ) ;
157+ /** Non-modal messages may never settle, so chain instead of awaiting. */
158+ private showPopup ( banners : readonly Announcement [ ] ) : void {
159+ Promise . resolve (
160+ vscode . window . showInformationMessage ( popupMessage ( banners ) , VIEW_ACTION ) ,
161+ )
162+ . then ( ( action ) =>
163+ // Banners are seconds old; skip the refetch.
164+ action === VIEW_ACTION ? this . pickAnnouncement ( ) : undefined ,
165+ )
166+ . catch ( ( error ) => {
167+ this . logger . warn ( "Failed to show Coder announcement popup" , error ) ;
168+ } ) ;
169+ }
170+
171+ private async pickAnnouncement ( ) : Promise < void > {
172+ const selected = await vscode . window . showQuickPick (
173+ this . banners . map ( ( banner , index ) => ( {
174+ label : `$(megaphone) Announcement ${ index + 1 } ` ,
175+ detail : banner . message ,
176+ banner,
177+ } ) ) ,
178+ {
179+ title : "Coder Announcements" ,
180+ placeHolder : "Select an announcement to view the full message" ,
181+ } ,
182+ ) ;
183+ if ( selected ) {
184+ void vscode . window . showInformationMessage ( selected . banner . message ) ;
172185 }
173186 }
174187
0 commit comments