@@ -3,6 +3,7 @@ import { buildConductorUrl } from '../conductor-url';
33import { createStorage } from '../storage' ;
44import type { Preset , Settings } from '../types' ;
55
6+ const WIDGET_ID = 'conductor-pr-widget' ;
67const BUTTON_ID = 'conductor-pr-button' ;
78const INJECTED_ATTR = 'data-conductor-injected' ;
89
@@ -13,9 +14,8 @@ void (async () => {
1314 currentSettings = await storage . getSettings ( ) ;
1415 storage . onChange ( ( s ) => {
1516 currentSettings = s ;
16- // Re-render so the button label reflects the latest default preset name.
17- const existing = document . getElementById ( BUTTON_ID ) ;
18- existing ?. remove ( ) ;
17+ // Re-render so the widget label reflects the latest default preset name.
18+ document . getElementById ( WIDGET_ID ) ?. remove ( ) ;
1919 tryInject ( ) ;
2020 } ) ;
2121 setupNavigationListeners ( ) ;
@@ -73,60 +73,117 @@ function setupNavigationListeners(): void {
7373 document . addEventListener ( event , ( ) => tryInject ( ) ) ;
7474 }
7575
76- // Fallback: a MutationObserver watching the header region. When GitHub
77- // re-renders the PR header after navigation, we re-inject our button.
76+ // GitHub's PR sidebar mounts asynchronously after the initial document
77+ // render. We watch the whole body so any time the sidebar (re)appears we
78+ // try injecting again. The injection itself is idempotent — if the widget
79+ // is already present, tryInject() returns early.
7880 const observer = new MutationObserver ( ( ) => {
79- if ( ! document . getElementById ( BUTTON_ID ) ) tryInject ( ) ;
81+ if ( ! document . getElementById ( WIDGET_ID ) ) tryInject ( ) ;
8082 } ) ;
8183 observer . observe ( document . body , { childList : true , subtree : true } ) ;
8284}
8385
8486function tryInject ( ) : void {
8587 if ( ! isPrPage ( window . location ) ) return ;
86- if ( document . getElementById ( BUTTON_ID ) ) return ;
88+ if ( document . getElementById ( WIDGET_ID ) ) return ;
8789
88- // Try several anchor points so we degrade gracefully across GitHub
89- // redesigns. We insert next to the existing "Code" / "Subscribe" actions.
90- const anchor = findAnchor ( ) ;
91- if ( ! anchor ) return ;
90+ const placement = findPlacement ( ) ;
91+ if ( ! placement ) return ;
9292
93- const button = createButton ( ) ;
94- anchor . parentElement ? .insertBefore ( button , anchor ) ;
93+ const widget = createWidget ( ) ;
94+ placement . parent . insertBefore ( widget , placement . before ) ;
9595}
9696
97- function findAnchor ( ) : Element | null {
98- // Preferred: the right-side header actions container on the PR conversation page.
99- const selectors = [
100- '.gh-header-actions' ,
101- '.gh-header-meta + div .gh-header-actions' ,
102- '[data-testid="pr-header-actions"]' ,
103- ] ;
104- for ( const sel of selectors ) {
105- const el = document . querySelector ( sel ) ;
106- if ( el ) {
107- // Insert as a sibling at the start of the actions row.
108- return el . firstElementChild ?? el ;
109- }
97+ interface Placement {
98+ parent : Element ;
99+ /** Element to insert *before*. `null` means append as last child. */
100+ before : Element | null ;
101+ }
102+
103+ /**
104+ * Find where to insert the Conductor widget.
105+ *
106+ * Preferred location: directly above the Reviewers section in the PR's right
107+ * sidebar (`#reviewers-select-menu`). We fall back to the top of the sidebar,
108+ * then to the PR header — so the button stays visible across GitHub
109+ * redesigns and across the PR conversation/files/commits tabs.
110+ *
111+ * Selectors are taken from the patterns Refined GitHub uses
112+ * (`#partial-discussion-sidebar`, `#reviewers-select-menu`), which have been
113+ * stable for years because they come from server-side Rails partials.
114+ */
115+ function findPlacement ( ) : Placement | null {
116+ // 1. Best: insert above the Reviewers section
117+ const reviewers = document . querySelector ( '#reviewers-select-menu' ) ;
118+ if ( reviewers ?. parentElement ) {
119+ return { parent : reviewers . parentElement , before : reviewers } ;
120+ }
121+
122+ // 2. Next best: prepend to the sidebar so it's still in the right column
123+ const sidebar = document . querySelector ( '#partial-discussion-sidebar' ) ;
124+ if ( sidebar ) {
125+ return { parent : sidebar , before : sidebar . firstElementChild } ;
110126 }
127+
128+ // 3. Fallback: next to the PR header actions
129+ const headerActions = document . querySelector (
130+ '.gh-header-actions, [data-testid="pr-header-actions"]' ,
131+ ) ;
132+ if ( headerActions ) {
133+ return { parent : headerActions , before : headerActions . firstElementChild } ;
134+ }
135+
111136 return null ;
112137}
113138
114- function createButton ( ) : HTMLButtonElement {
139+ /**
140+ * Build the sidebar widget — styled like a native GitHub `discussion-sidebar-item`.
141+ *
142+ * Layout:
143+ * ┌─────────────────────────────┐
144+ * │ Conductor │ ← heading (matches native sidebar)
145+ * ├─────────────────────────────┤
146+ * │ [▶ Conductor: Review PR ⌄] │ ← primary button + preset dropdown
147+ * └─────────────────────────────┘
148+ */
149+ function createWidget ( ) : HTMLElement {
150+ const wrapper = document . createElement ( 'div' ) ;
151+ wrapper . id = WIDGET_ID ;
152+ wrapper . className = 'conductor-widget discussion-sidebar-item' ;
153+ wrapper . setAttribute ( INJECTED_ATTR , 'true' ) ;
154+
155+ const heading = document . createElement ( 'h3' ) ;
156+ heading . className = 'conductor-widget-heading discussion-sidebar-heading' ;
157+ heading . textContent = 'Conductor' ;
158+ wrapper . appendChild ( heading ) ;
159+
160+ const row = document . createElement ( 'div' ) ;
161+ row . className = 'conductor-widget-row' ;
162+ wrapper . appendChild ( row ) ;
163+
164+ row . appendChild ( createPrimaryButton ( ) ) ;
165+ if ( currentSettings && currentSettings . presets . length > 1 ) {
166+ row . appendChild ( createPresetSelect ( ) ) ;
167+ }
168+
169+ return wrapper ;
170+ }
171+
172+ function createPrimaryButton ( ) : HTMLButtonElement {
115173 const button = document . createElement ( 'button' ) ;
116174 button . id = BUTTON_ID ;
117175 button . type = 'button' ;
118- button . className = 'conductor-pr-button btn btn-sm' ;
119- button . setAttribute ( INJECTED_ATTR , 'true' ) ;
176+ button . className = 'conductor-pr-button' ;
120177 button . title = 'Open this PR in a new Conductor workspace' ;
121178
122- // Build the label with safe DOM construction (no innerHTML).
123179 const icon = document . createElement ( 'span' ) ;
124180 icon . className = 'conductor-pr-button-icon' ;
125181 icon . setAttribute ( 'aria-hidden' , 'true' ) ;
126- icon . textContent = '▶' ; // ▶
182+ icon . textContent = '▶' ;
127183 button . appendChild ( icon ) ;
128184
129185 const label = document . createElement ( 'span' ) ;
186+ label . className = 'conductor-pr-button-label' ;
130187 const presetName = currentSettings ? getDefaultPreset ( currentSettings ) ?. name : null ;
131188 label . textContent = presetName ? `Conductor: ${ presetName } ` : 'Open in Conductor' ;
132189 button . appendChild ( label ) ;
@@ -139,6 +196,44 @@ function createButton(): HTMLButtonElement {
139196 return button ;
140197}
141198
199+ /**
200+ * Dropdown listing every configured preset (visible when there are 2+).
201+ *
202+ * Choosing a preset from the dropdown immediately fires that preset and
203+ * resets the select to its first option, so the dropdown acts as a one-shot
204+ * launcher rather than a persistent selector.
205+ */
206+ function createPresetSelect ( ) : HTMLSelectElement {
207+ const select = document . createElement ( 'select' ) ;
208+ select . className = 'conductor-preset-select' ;
209+ select . title = 'Pick a different preset' ;
210+
211+ const placeholder = document . createElement ( 'option' ) ;
212+ placeholder . value = '' ;
213+ placeholder . textContent = '⌄' ;
214+ placeholder . disabled = true ;
215+ placeholder . selected = true ;
216+ select . appendChild ( placeholder ) ;
217+
218+ if ( currentSettings ) {
219+ for ( const preset of currentSettings . presets ) {
220+ const opt = document . createElement ( 'option' ) ;
221+ opt . value = preset . id ;
222+ opt . textContent = preset . name ;
223+ select . appendChild ( opt ) ;
224+ }
225+ }
226+
227+ select . addEventListener ( 'change' , ( ) => {
228+ const presetId = select . value ;
229+ select . selectedIndex = 0 ;
230+ if ( ! presetId ) return ;
231+ void runPreset ( presetId ) ;
232+ } ) ;
233+
234+ return select ;
235+ }
236+
142237function getDefaultPreset ( settings : Settings ) : Preset | undefined {
143238 return settings . presets . find ( ( p ) => p . id === settings . defaultPresetId ) ?? settings . presets [ 0 ] ;
144239}
0 commit comments