tabby-rdp 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@ exports.RemoteDesktopSettingsComponent = exports.RemoteDesktopSettingsTab = void
13
13
  const core_1 = require("@angular/core");
14
14
  const tabby_core_1 = require("tabby-core");
15
15
  const tabby_settings_1 = require("tabby-settings");
16
+ const accounts_1 = require("./accounts");
16
17
  const desktop_service_1 = require("./desktop.service");
17
18
  const desktops_1 = require("./desktops");
18
19
  const osd_1 = require("./osd");
@@ -21,26 +22,69 @@ const updates_1 = require("./updates");
21
22
  const targets_1 = require("./targets");
22
23
  const REPO = 'https://github.com/meanaverage/tabby-rdp';
23
24
  const k = (s) => `<kbd>${(0, help_1.esc)(s)}</kbd>`;
25
+ /** The page's tabs (as Tabby's "Profiles & connections" page has Profiles and Advanced), each holding sections by topic. */
26
+ const TABS = [
27
+ { id: 'start', title: 'Getting started', topics: ['start', 'keyboard'] },
28
+ { id: 'settings', title: 'Settings', topics: ['settings'] },
29
+ { id: 'osd', title: 'Overlay', topics: ['osd'] },
30
+ { id: 'desktops', title: 'Desktops', topics: ['desktops', 'certificates'] },
31
+ { id: 'accounts', title: 'Accounts', topics: ['accounts'] },
32
+ { id: 'troubleshooting', title: 'Troubleshooting', topics: ['troubleshooting'] },
33
+ ];
34
+ /** The tab shown last, so that the page comes back to it. */
35
+ let lastTab = 'start';
36
+ /** The desktop resolution the overlay preview stands for (the overlay's size depends on it). */
37
+ let previewResolution = '1920x1080';
24
38
  const STYLE = `
25
39
  .trd-settings { display: block; }
26
40
  .trd-settings h3 { margin-bottom: 4px; }
27
41
  .trd-settings .trd-lead { margin-bottom: 18px; opacity: 0.75; }
28
42
  .trd-settings .trd-links { white-space: nowrap; }
29
43
  .trd-settings .trd-lead a { margin-left: 10px; }
44
+ .trd-settings section .trd-lead a { margin-left: 0; cursor: pointer; text-decoration: underline; }
30
45
  .trd-settings .trd-update { display: flex; gap: 10px; align-items: center; margin-bottom: 18px; padding: 10px 12px; border-radius: 6px;
31
46
  border: 1px solid rgba(80, 150, 255, 0.45); background: rgba(80, 150, 255, 0.1); }
32
47
  .trd-settings .trd-update > div { flex: auto; }
33
48
  .trd-settings .trd-update:empty { display: none; }
49
+ .trd-settings .nav-tabs { margin-bottom: 18px; }
50
+ .trd-settings .nav-tabs .nav-link { cursor: pointer; }
34
51
  .trd-settings section { margin-bottom: 28px; scroll-margin-top: 12px; }
35
52
  .trd-settings section > h4 { font-size: 15px; margin-bottom: 10px; }
53
+ .trd-settings section > h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.6; margin: 22px 0 6px; }
54
+ .trd-settings section > h5:first-of-type { margin-top: 4px; }
55
+ .trd-settings .form-line .description a { cursor: pointer; text-decoration: underline; }
36
56
  .trd-settings .trd-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; }
37
57
  .trd-settings .trd-card { display: flex; flex-direction: column; gap: 8px; padding: 12px; border-radius: 6px;
38
58
  border: 1px solid rgba(128, 128, 128, 0.25); }
39
- .trd-settings .trd-card .trd-card-title { font-weight: 600; }
59
+ .trd-settings .trd-card .trd-card-title { font-weight: 600; display: flex; align-items: center; gap: 6px; }
60
+ .trd-settings h4 .trd-info, .trd-settings .trd-lead .trd-info { margin-left: 4px; vertical-align: middle; font-size: 13px; }
61
+ .trd-settings .trd-info { position: relative; display: inline-flex; opacity: 0.55; cursor: help; outline: none; font-weight: normal; }
62
+ .trd-settings .trd-info:hover, .trd-settings .trd-info:focus { opacity: 1; }
63
+ .trd-settings .trd-info::after { content: attr(data-tip); position: absolute; left: 0; top: calc(100% + 6px); z-index: 20; width: 260px;
64
+ padding: 8px 10px; border-radius: 6px; font-size: 12px; font-weight: normal; line-height: 1.4; white-space: normal;
65
+ background: var(--theme-bg-more, #2a2a2a); color: var(--bs-body-color, #ddd); border: 1px solid rgba(128, 128, 128, 0.35);
66
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); opacity: 0; visibility: hidden; transition: opacity 120ms; pointer-events: none; }
67
+ .trd-settings .trd-info:hover::after, .trd-settings .trd-info:focus::after { opacity: 1; visibility: visible; }
40
68
  .trd-settings .trd-card .trd-card-text { flex: auto; font-size: 12px; opacity: 0.8; }
41
69
  .trd-settings .trd-card button { align-self: flex-start; }
42
- .trd-settings kbd { padding: 1px 5px; border-radius: 3px; font-size: 11px; background: rgba(128, 128, 128, 0.25);
70
+ .trd-settings kbd { padding: 2px 7px; border-radius: 3px; font-size: 13px; background: rgba(128, 128, 128, 0.25);
43
71
  color: inherit; white-space: nowrap; }
72
+ .trd-settings .trd-hotkeys .row { border-bottom: 1px solid rgba(128, 128, 128, 0.15); }
73
+ .trd-settings .trd-hotkeys .trd-hotkey-name { font-size: 13px; }
74
+ .trd-settings .trd-hotkeys .trd-conflict { font-size: 12px; color: var(--bs-danger, #e55); }
75
+ .trd-settings .trd-hotkeys multi-hotkey-input { display: flex; flex-wrap: wrap; gap: 5px 0; font-size: 13px; }
76
+ .trd-settings .trd-hotkeys multi-hotkey-input .item, .trd-settings .trd-hotkeys multi-hotkey-input .item .body { display: flex; cursor: pointer; }
77
+ .trd-settings .trd-hotkeys multi-hotkey-input .add { cursor: pointer; }
78
+ .trd-capture { position: fixed; inset: 0; z-index: 2000; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.45); }
79
+ .trd-capture hotkey-input-modal { display: block; width: 420px; max-width: calc(100% - 32px); border-radius: 6px; background: var(--bs-body-bg, #222);
80
+ border: 1px solid rgba(128, 128, 128, 0.35); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); }
81
+ .trd-capture .modal-header, .trd-capture .modal-footer { padding: 12px 16px; }
82
+ .trd-capture .modal-header h5 { margin: 0; }
83
+ .trd-capture .modal-body { padding: 0 16px 10px; }
84
+ .trd-capture .input { display: flex; align-items: center; }
85
+ .trd-capture .input:empty::before { content: 'Press the key now'; opacity: 0.4; font-size: 14px; }
86
+ .trd-capture .timeout { height: 3px; border-radius: 2px; overflow: hidden; }
87
+ .trd-capture .timeout div { height: 100%; transition: width 25ms linear; }
44
88
  .trd-settings .trd-keys { width: 100%; font-size: 13px; }
45
89
  .trd-settings .trd-keys td { padding: 5px 0; vertical-align: top; border-bottom: 1px solid rgba(128, 128, 128, 0.15); }
46
90
  .trd-settings .trd-keys td:first-child { width: 38%; padding-right: 12px; }
@@ -50,6 +94,21 @@ const STYLE = `
50
94
  .trd-settings .trd-row > div { flex: auto; min-width: 0; }
51
95
  .trd-settings .trd-row .trd-sub { font-size: 12px; opacity: 0.7; overflow: hidden; text-overflow: ellipsis; }
52
96
  .trd-settings .trd-empty { font-size: 12px; opacity: 0.7; }
97
+ .trd-settings .trd-account-form { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 12px; border-radius: 6px;
98
+ border: 1px solid rgba(128, 128, 128, 0.35); }
99
+ .trd-settings .trd-account-form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; margin: 0; }
100
+ .trd-settings .trd-account-form .trd-account-error { grid-column: 1 / -1; color: #f08080; font-size: 12px; }
101
+ .trd-settings .trd-account-form .trd-account-error:empty { display: none; }
102
+ .trd-settings .trd-account-form .trd-account-buttons { grid-column: 1 / -1; display: flex; gap: 8px; justify-content: flex-end; }
103
+ .trd-settings .trd-list + .trd-add { margin-top: 10px; display: flex; gap: 6px; align-items: center; }
104
+ .trd-settings .trd-row a[data-profile], .trd-settings .trd-row a[data-desktop] { cursor: pointer; text-decoration: underline; }
105
+ .trd-settings .trd-form-overlay { position: fixed; inset: 0; z-index: 1050; background: rgba(0, 0, 0, 0.55); }
106
+ /* The window stays draggable by its tab bar while the form shows (Tabby's own dialogs cover it). */
107
+ .trd-settings .trd-form-overlay .trd-form-dragbar, .trd-capture .trd-form-dragbar { display: block; position: absolute; left: 0; right: 0; top: 0;
108
+ height: var(--tabs-height, 38px); -webkit-app-region: drag; }
109
+ .trd-settings .trd-form-overlay .trd-signin { top: var(--tabs-height, 38px); }
110
+ .trd-settings .trd-form-overlay .trd-signin form { padding: 20px; border-radius: 6px; background: var(--bs-body-bg, #1b1b1b);
111
+ border: 1px solid rgba(128, 128, 128, 0.35); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); width: 420px; }
53
112
  .trd-settings details { border-bottom: 1px solid rgba(128, 128, 128, 0.15); padding: 6px 0; }
54
113
  .trd-settings details summary { cursor: pointer; }
55
114
  .trd-settings details > div { padding: 6px 0 4px 16px; font-size: 13px; opacity: 0.85; }
@@ -62,6 +121,7 @@ const STYLE = `
62
121
  .trd-settings .trd-osd-preview { position: relative; aspect-ratio: 16 / 9; max-width: 520px; margin: 4px 0 14px; border-radius: 6px;
63
122
  overflow: hidden; background: linear-gradient(115deg, #e9edf1 0%, #cfd6dd 38%, #3a4048 62%, #1c1f24 100%); }
64
123
  .trd-settings .trd-osd-stage { position: absolute; top: 0; left: 0; width: 1280px; height: 720px; transform-origin: 0 0; }
124
+ .trd-settings .trd-osd-resolution { position: absolute; left: 10px; bottom: 10px; width: auto; padding-right: 30px; font-size: 11px; opacity: 0.85; }
65
125
  .trd-settings .trd-osd-preview .trd-osd { opacity: 1; }
66
126
  .trd-settings .trd-color { display: flex; gap: 8px; align-items: center; }
67
127
  .trd-settings .trd-color input[type=color] { width: 40px; height: 28px; padding: 0; border: 0; background: none; }
@@ -87,7 +147,7 @@ exports.RemoteDesktopSettingsTab = RemoteDesktopSettingsTab = __decorate([
87
147
  * troubleshooting. Plain DOM like the rest of the plugin; the lists follow config changes made elsewhere.
88
148
  */
89
149
  let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
90
- constructor(element, desktop, help, config, platform, injector, zone) {
150
+ constructor(element, desktop, help, config, platform, injector, zone, hotkeys) {
91
151
  this.element = element;
92
152
  this.desktop = desktop;
93
153
  this.help = help;
@@ -95,9 +155,12 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
95
155
  this.platform = platform;
96
156
  this.injector = injector;
97
157
  this.zone = zone;
158
+ this.hotkeys = hotkeys;
98
159
  this.contentBox = true;
99
160
  this.trdSettings = true;
100
161
  this.subscriptions = [];
162
+ /** Every hotkey Tabby knows, by id, for naming what a binding of ours collides with. */
163
+ this.hotkeyNames = null;
101
164
  }
102
165
  ngOnInit() {
103
166
  if (!document.getElementById('trd-settings-style')) {
@@ -107,11 +170,11 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
107
170
  document.head.appendChild(style);
108
171
  }
109
172
  this.render();
110
- // The preview is a 1280×720 desktop, scaled down to fit the page.
173
+ // A known newer version may be stale by now (the page is where it shows): ask npm again.
174
+ this.injector.get(updates_1.UpdateCheck).refresh();
175
+ // The preview is a desktop of the chosen resolution, scaled down to fit the page.
111
176
  const frame = this.root.querySelector('.trd-osd-preview');
112
- const scale = new ResizeObserver(() => {
113
- frame.querySelector('.trd-osd-stage').style.transform = `scale(${frame.clientWidth / 1280})`;
114
- });
177
+ const scale = new ResizeObserver(() => this.scalePreview());
115
178
  scale.observe(frame);
116
179
  this.subscriptions.push({ unsubscribe: () => scale.disconnect() });
117
180
  this.subscriptions.push(this.config.changed$.subscribe(() => this.refresh()), this.desktop.changed$.subscribe(() => this.refresh()), this.help.show$.subscribe(({ topic, entry }) => setTimeout(() => this.reveal(topic, entry))), this.injector.get(updates_1.UpdateCheck).changed$.subscribe(() => this.refresh()));
@@ -143,27 +206,24 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
143
206
  ${version ? `<span>tabby-rdp ${(0, help_1.esc)(version)}</span>` : ''}
144
207
  <span class="trd-links"><a href="#" data-link="${REPO}#readme">Guide</a><a href="#" data-link="${REPO}/issues">Report a problem</a></span></div>
145
208
  <div class="trd-update" data-update></div>
209
+ <ul class="nav nav-tabs" data-nav></ul>
146
210
 
147
211
  <section data-topic="start">
148
212
  <h4>Getting started</h4>
149
213
  <div class="trd-cards">
150
214
  <div class="trd-card">
151
- <div class="trd-card-title">Over SSH</div>
152
- <div class="trd-card-text">In an SSH tab, press <span data-hotkey></span> or click <b>Desktop</b> in its
153
- toolbar. The host is set up the first time (GNOME, xrdp, or Windows with OpenSSH); nothing to
154
- install here, no ports to open.</div>
215
+ <div class="trd-card-title">Over SSH ${this.info('The host is set up on the first connection: GNOME, xrdp, or Windows with OpenSSH. Nothing to install here, no ports to open.')}</div>
216
+ <div class="trd-card-text">In an SSH tab, press <span data-hotkey></span> or click <b>Desktop</b>.</div>
155
217
  <button class="btn btn-secondary btn-sm" data-action="ssh">Open a connection…</button>
156
218
  </div>
157
219
  <div class="trd-card">
158
- <div class="trd-card-title">On your network</div>
159
- <div class="trd-card-text">A remote desktop profile connects straight to an RDP server's address, in a
160
- tab of its own. Or type <code>user@host</code> in the profile selector.</div>
220
+ <div class="trd-card-title">On your network ${this.info('The desktop opens in a tab of its own. For a quick connection, type user@host in the profile selector.')}</div>
221
+ <div class="trd-card-text">A profile that connects straight to an RDP server.</div>
161
222
  <button class="btn btn-secondary btn-sm" data-action="profile">New profile…</button>
162
223
  </div>
163
224
  <div class="trd-card">
164
- <div class="trd-card-title">From an .rdp file</div>
165
- <div class="trd-card-text">A file from Remote Desktop Connection or your admin becomes a profile: its
166
- address, user name and domain.</div>
225
+ <div class="trd-card-title">From an .rdp file ${this.info('A file from Remote Desktop Connection, or from your admin. Its address, user name and domain make the profile; the password is asked for.')}</div>
226
+ <div class="trd-card-text">The file becomes a profile.</div>
167
227
  <button class="btn btn-secondary btn-sm" data-action="import">Import an .rdp file…</button>
168
228
  </div>
169
229
  </div>
@@ -171,6 +231,7 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
171
231
 
172
232
  <section data-topic="settings">
173
233
  <h4>Settings</h4>
234
+ <h5>Picture</h5>
174
235
  <div class="form-line">
175
236
  <div class="header">
176
237
  <div class="title">When the pane is resized</div>
@@ -195,12 +256,26 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
195
256
  <option value="retina">Retina</option>
196
257
  </select>
197
258
  </div>
198
- ${this.toggleLine('sound', 'Sound', 'Play the remote desktop\'s sound here. Applies on the next connection.')}
199
- ${this.toggleLine('microphone', 'Microphone', 'Send your microphone while an app there records, with a red dot in the corner meanwhile. Applies on the next connection.')}
200
259
  ${this.toggleLine('h264', 'Video decoding (H.264)', 'Decodes what the remote sends as video, hardware-accelerated where available. Applies on the next connection.')}
201
260
  ${this.toggleLine('connectionStatus', 'Show connection status', 'A small line in the desktop\'s corner: throughput, frames per second, round trip, and how it\'s connected.')}
261
+
262
+ <h5>Sound</h5>
263
+ ${this.toggleLine('sound', 'Sound', 'Play the remote desktop\'s sound here. Applies on the next connection.')}
264
+ ${this.toggleLine('microphone', 'Microphone', 'Send your microphone while an app there records, with a red dot in the corner meanwhile. Applies on the next connection.')}
265
+
266
+ <h5>Keyboard</h5>
202
267
  ${mac ? this.toggleLine('macShortcuts', 'Mac-style shortcuts', 'Use ⌘ as Ctrl on the desktop, so ⌘C copies and ⌘V pastes. Tap ⌘ on its own for the Windows key. When off, ⌘ is always the Windows key.') : ''}
203
- ${this.toggleLine('checkUpdates', 'Tell me about new versions', 'Once a day, asks npm (registry.npmjs.org) for the latest tabby-rdp, and says so here and in the menus when there is one. Nothing is sent but the request. Tabby itself shows plugin upgrades only on its Plugins page.')}
268
+ <div class="form-line">
269
+ <div class="header">
270
+ <div class="title">Shortcuts</div>
271
+ <div class="description">Switching between the desktop and the console, view only, screenshots and more: on the
272
+ <a data-action="shortcuts">Getting started</a> tab, and in Tabby's Hotkeys.</div>
273
+ </div>
274
+ <div><span data-hotkey></span></div>
275
+ </div>
276
+
277
+ <h5>SSH hosts</h5>
278
+ ${this.toggleLine('desk', 'desk: bring the console along', `Type <code>desk</code> in an SSH console to show that same shell on the desktop. Installs a small helper and a login line on GNOME hosts the next time a desktop opens there; off removes them.`)}
204
279
  ${this.toggleLine('discoverVMs', 'Find virtual machines on SSH hosts', 'Lists the host\'s libvirt VMs that have a desktop (RDP answering, or Windows and shut off) in its tab\'s menu, ready to open or start. Read-only: it runs virsh as you there, at most once a minute.')}
205
280
  <div class="form-line">
206
281
  <div class="header">
@@ -216,21 +291,22 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
216
291
  <option value="60">After an hour</option>
217
292
  </select>
218
293
  </div>
219
- ${this.toggleLine('desk', 'desk: bring the console along', `Type <code>desk</code> in an SSH console to show that same shell on the desktop. Installs a small helper and a login line on GNOME hosts the next time a desktop opens there; off removes them.`)}
220
- <div class="form-line">
221
- <div class="header">
222
- <div class="title">Switch between desktop and console</div>
223
- <div class="description">Also the <b>Desktop</b> button in an SSH tab's toolbar and in Tabby's header.</div>
224
- </div>
225
- <div><span data-hotkey></span> <button class="btn btn-link btn-sm" data-action="hotkeys">Change…</button></div>
226
- </div>
294
+
295
+ <h5>Updates</h5>
296
+ ${this.toggleLine('checkUpdates', 'Tell me about new versions', 'Once a day, asks npm (registry.npmjs.org) for the latest tabby-rdp, and says so here and in the menus when there is one. Nothing is sent but the request. Tabby itself shows plugin upgrades only on its Plugins page.')}
227
297
  </section>
228
298
 
229
299
  <section data-topic="osd">
230
300
  <h4>Desktop name overlay</h4>
231
301
  <div class="trd-lead">Names the desktop for a moment when it connects, when you switch to it, and when you click
232
302
  into its pane, like a TV naming its input.</div>
233
- <div class="trd-osd-preview"><div class="trd-osd-stage"><div class="trd-osd"></div></div></div>
303
+ <div class="trd-osd-preview"><div class="trd-osd-stage"><div class="trd-osd"></div></div>
304
+ <select class="form-control form-control-sm trd-osd-resolution" data-preview-resolution title="The desktop resolution the preview stands for: the overlay's size on screen depends on it">
305
+ <option value="1280x720">1280 × 720</option>
306
+ <option value="1920x1080">1920 × 1080</option>
307
+ <option value="2560x1440">2560 × 1440</option>
308
+ <option value="3840x2160">3840 × 2160</option>
309
+ </select></div>
234
310
  <div class="form-line">
235
311
  <div class="header"><div class="title">Show</div>
236
312
  <div class="description">"When it helps": in split panes, and for a desktop other than the tab's own
@@ -271,9 +347,11 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
271
347
  </section>
272
348
 
273
349
  <section data-topic="keyboard">
274
- <h4>Keys and tips</h4>
350
+ <h4>Shortcuts ${this.info('These work while the desktop has the keyboard. Tabby\'s other shortcuts go to the desktop then, except switching tabs and full screen.')}</h4>
351
+ <div class="trd-lead">Also listed in Tabby's <a data-action="hotkeys">Hotkeys</a>.</div>
352
+ <div class="trd-hotkeys" data-list="hotkeys"></div>
353
+ <h4 style="margin-top: 24px">Keys and tips</h4>
275
354
  <table class="trd-keys"><tbody>
276
- <tr><td data-hotkey-cell></td><td>Switch between the desktop and the SSH console. The desktop keeps running.</td></tr>
277
355
  ${mac ? `
278
356
  <tr><td>${k('⌘C')} ${k('⌘V')} ${k('⌘Z')} …</td><td>Work as on a Mac (sent as Ctrl).</td></tr>
279
357
  <tr><td>Tap ${k('⌘')}</td><td>The Windows key (Start, or GNOME's Activities).</td></tr>
@@ -282,13 +360,26 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
282
360
  <tr><td>Drop files on the desktop</td><td>Then paste them in Files or Explorer. Files copied there offer <b>Save to Downloads</b>.</td></tr>
283
361
  <tr><td>Right-click › <b>View only</b></td><td>Watch without touching: no keys or clicks go to the desktop.</td></tr>
284
362
  <tr><td>Right-click › <b>Save a screenshot</b></td><td>The full-resolution screen, to Downloads and the clipboard.</td></tr>
285
- <tr><td>Tabby's shortcuts</td><td>While a desktop shows, only switching tabs, full screen and the switch above stay Tabby's; the rest go to the desktop.</td></tr>
286
363
  </tbody></table>
287
364
  </section>
288
365
 
289
366
  <section data-topic="desktops">
290
- <h4>Desktops behind SSH hosts</h4>
367
+ <h4>Desktops</h4>
368
+ <div class="trd-lead">Remote desktop profiles, and desktops added from SSH tabs.
369
+ ${this.info('A desktop added from an SSH tab\'s menu belongs to that host. That is for hosts without a profile, such as an ssh typed in a terminal.')}</div>
291
370
  <div class="trd-list" data-list="desktops"></div>
371
+ <div class="trd-add"><button class="btn btn-secondary btn-sm" data-action="desktop">Add a desktop…</button></div>
372
+ </section>
373
+
374
+ <section data-topic="accounts">
375
+ <h4>Accounts</h4>
376
+ <div class="trd-lead">One sign-in for several desktops: when its password changes, change it once here.
377
+ ${this.info('A desktop picks its account in its profile\'s settings, or in the form that adds a desktop behind an SSH host. A profile group\'s defaults can pick one for all of its profiles.')}</div>
378
+ <div class="trd-list" data-list="accounts"></div>
379
+ <div class="trd-add">
380
+ <button class="btn btn-secondary btn-sm" data-action="account">Add an account…</button>
381
+ <button class="btn btn-link btn-sm" data-action="profiles">Profiles &amp; connections…</button>
382
+ </div>
292
383
  </section>
293
384
 
294
385
  <section data-topic="certificates">
@@ -304,9 +395,42 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
304
395
  <h4 style="margin-top: 18px">Open desktops</h4>
305
396
  <div class="trd-list" data-list="sessions"></div>
306
397
  </section>`;
398
+ // The sections go into the tabs' bodies; one tab shows at a time.
399
+ const nav = this.root.querySelector('[data-nav]');
400
+ for (const tab of TABS) {
401
+ const item = document.createElement('li');
402
+ item.className = 'nav-item';
403
+ item.innerHTML = `<a class="nav-link" data-tab="${tab.id}">${(0, help_1.esc)(tab.title)}</a>`;
404
+ item.querySelector('a').addEventListener('click', event => {
405
+ event.preventDefault();
406
+ this.showTab(tab.id);
407
+ });
408
+ nav.appendChild(item);
409
+ const body = document.createElement('div');
410
+ body.dataset.body = tab.id;
411
+ body.hidden = true;
412
+ const sections = tab.topics.map(topic => this.root.querySelector(`section[data-topic="${topic}"]`)).filter((e) => !!e);
413
+ // The tab's name is heading enough: a section titled the same, or alone in its tab, drops its own.
414
+ for (const section of sections) {
415
+ const heading = section.querySelector(':scope > h4');
416
+ if (heading && (sections.length === 1 || heading.textContent?.trim() === tab.title)) {
417
+ heading.remove();
418
+ }
419
+ }
420
+ body.append(...sections);
421
+ this.root.appendChild(body);
422
+ }
423
+ this.showTab(lastTab);
307
424
  this.root.querySelectorAll('[data-setting]').forEach(el => {
308
425
  el.addEventListener('change', () => this.zone.run(() => this.changeSetting(el)));
309
426
  });
427
+ const resolution = this.root.querySelector('[data-preview-resolution]');
428
+ resolution.value = previewResolution;
429
+ resolution.addEventListener('change', () => {
430
+ previewResolution = resolution.value;
431
+ this.scalePreview();
432
+ this.refresh();
433
+ });
310
434
  this.root.querySelectorAll('[data-osd]').forEach(el => {
311
435
  // The color picker reports while dragging (input) and when closed (change).
312
436
  el.addEventListener(el.type === 'color' ? 'input' : 'change', () => this.zone.run(() => {
@@ -323,6 +447,10 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
323
447
  profile: () => this.newProfile(),
324
448
  import: () => this.desktop.importRdpFile(),
325
449
  hotkeys: () => this.showTabbySettings('hotkeys'),
450
+ shortcuts: () => this.reveal('keyboard'),
451
+ account: () => this.editAccount(null),
452
+ desktop: () => this.newProfile(),
453
+ profiles: () => this.showTabbySettings('profiles'),
326
454
  'osd-white': () => this.changeOsd({ color: '' }),
327
455
  'osd-try': () => {
328
456
  if (!this.desktop.showOsdEverywhere()) {
@@ -335,6 +463,22 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
335
463
  }));
336
464
  this.refresh();
337
465
  }
466
+ /** An ⓘ that shows `text` while hovered or focused: detail that the card itself needn't carry. */
467
+ info(text) {
468
+ return `<span class="trd-info" tabindex="0" role="note" data-tip="${(0, help_1.esc)(text)}"><i class="fas fa-info-circle"></i></span>`;
469
+ }
470
+ /** Sizes the preview's stage to the chosen resolution and scales it to the frame's width. */
471
+ scalePreview() {
472
+ const frame = this.root.querySelector('.trd-osd-preview');
473
+ const stage = frame?.querySelector('.trd-osd-stage');
474
+ if (!frame || !stage) {
475
+ return;
476
+ }
477
+ const [width, height] = previewResolution.split('x').map(Number);
478
+ stage.style.width = `${width}px`;
479
+ stage.style.height = `${height}px`;
480
+ stage.style.transform = `scale(${frame.clientWidth / width})`;
481
+ }
338
482
  toggleLine(key, title, description) {
339
483
  return `
340
484
  <div class="form-line">
@@ -390,17 +534,15 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
390
534
  });
391
535
  const stage = this.root.querySelector('.trd-osd-stage .trd-osd');
392
536
  if (stage) {
393
- (0, osd_1.renderOsd)(stage, 'workstation', 'via jumphost · 1280×720', osd);
537
+ (0, osd_1.renderOsd)(stage, 'workstation', `via jumphost · ${previewResolution.replace('x', '×')}`, osd);
394
538
  }
395
539
  const hotkey = this.help.toggleHotkey();
396
540
  this.root.querySelectorAll('[data-hotkey]').forEach(el => {
397
541
  el.innerHTML = hotkey ? k(hotkey) : '<i>(no shortcut)</i>';
398
542
  });
399
- const cell = this.root.querySelector('[data-hotkey-cell]');
400
- if (cell) {
401
- cell.innerHTML = hotkey ? k(hotkey) : 'The <b>Desktop</b> button';
402
- }
403
543
  this.renderDesktops();
544
+ this.renderHotkeys();
545
+ this.renderAccounts();
404
546
  this.renderCertificates();
405
547
  this.renderSessions();
406
548
  }
@@ -428,16 +570,335 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
428
570
  if (!list) {
429
571
  return;
430
572
  }
573
+ const accounts = this.desktop.accounts();
574
+ const accountOf = (id) => id ? accounts.find(a => a.id === id) : undefined;
575
+ // Remote desktop profiles first (Tabby's editor), then the desktops added from SSH tabs (the plugin's form).
576
+ const profiles = (this.config.store.profiles ?? []).filter((p) => p?.type === targets_1.RDP_PROFILE_TYPE && p.options?.host);
577
+ const groups = this.config.store.groups ?? [];
578
+ const profileRows = profiles.map(p => {
579
+ const o = p.options;
580
+ const address = `${o.host}:${o.port || 3389}`;
581
+ const via = o.via ? (this.config.store.profiles ?? []).find((x) => x.id === o.via)?.name ?? 'an SSH profile' : null;
582
+ const inherited = o.account === undefined ? groups.find(g => g.id === p.group)?.defaults?.[targets_1.RDP_PROFILE_TYPE]?.options?.account : undefined;
583
+ const account = accountOf(o.account || inherited);
584
+ const details = [
585
+ account ? `Signs in as ${(0, help_1.esc)(account.name)}` : o.username ? `Signs in as ${(0, help_1.esc)(o.username)}` : '',
586
+ o.kind && o.kind !== 'windows' ? (0, help_1.esc)(o.kind) : '',
587
+ ].filter(Boolean).join('<br>');
588
+ return this.row((0, help_1.esc)(p.name ?? address), `${(0, help_1.esc)(address)}${via ? ` via ${(0, help_1.esc)(via)}` : ''}${details ? `<br>${details}` : ''}`, [
589
+ { label: 'Edit…', run: () => { this.editProfile(p.id); } },
590
+ { label: 'Remove…', danger: true, run: () => { this.removeProfile(p.id); } },
591
+ ]);
592
+ });
431
593
  const desktops = this.desktop.configuredDesktops();
432
- list.replaceChildren(...desktops.map((d, i) => {
594
+ const desktopRows = desktops.map((d, i) => {
433
595
  const address = `${d.host ?? '127.0.0.1'}:${d.port ?? 3389}`;
434
- const wake = d.wake?.vm ? ` · starts VM ${(0, help_1.esc)(d.wake.vm)}` : d.wake?.mac ? ` · wakes ${(0, help_1.esc)(d.wake.mac)}` : '';
435
- return this.row((0, help_1.esc)(d.name ?? address), `${(0, help_1.esc)(address)} behind ${(0, help_1.esc)(d.via)} · ${(0, help_1.esc)(d.kind ?? 'windows')}${d.username ? ` · ${(0, help_1.esc)(d.username)}` : ''}${wake}`, [
596
+ const account = accountOf(d.account);
597
+ const details = [
598
+ account ? `Signs in as ${(0, help_1.esc)(account.name)}` : d.username ? `Signs in as ${(0, help_1.esc)(d.username)}` : '',
599
+ d.kind && d.kind !== 'windows' ? (0, help_1.esc)(d.kind) : '',
600
+ d.wake?.vm ? `Starts VM ${(0, help_1.esc)(d.wake.vm)} when off` : d.wake?.mac ? `Wakes ${(0, help_1.esc)(d.wake.mac)} when off` : '',
601
+ ].filter(Boolean).join('<br>');
602
+ return this.row((0, help_1.esc)(d.name ?? address), `${(0, help_1.esc)(address)} behind ${(0, help_1.esc)(d.via)}${details ? `<br>${details}` : ''}`, [
603
+ { label: 'Edit…', run: () => { this.desktop.editDesktopIn(this.root, i, true); } },
436
604
  { label: 'Remove…', danger: true, run: () => { this.desktop.confirmRemoveDesktop(i); } },
437
605
  ]);
438
- }), this.empty(desktops.length
439
- ? 'To edit one, right-click its host\'s SSH tab › Settings › Edit a desktop.'
440
- : 'None yet. A Windows VM or another computer an SSH host can reach: right-click that host\'s SSH tab › <b>Desktops › Add a desktop behind…</b>'));
606
+ });
607
+ list.replaceChildren(...profileRows, ...desktopRows, this.empty(profileRows.length + desktopRows.length
608
+ ? 'Profiles are also in Profiles &amp; connections; a desktop behind a host also in that host\'s SSH tab: right-click › Settings.'
609
+ : 'None yet. <b>Add a desktop…</b> makes a profile: an RDP server on your network, or one behind an SSH profile\'s host.'));
610
+ }
611
+ renderAccounts() {
612
+ const list = this.root.querySelector('[data-list=accounts]');
613
+ // The form holds what is being typed: a config change elsewhere doesn't rebuild the list under it.
614
+ if (!list || list.querySelector('.trd-account-form')) {
615
+ return;
616
+ }
617
+ const accounts = this.desktop.accounts();
618
+ // The password checks one after another: each can wait on a keychain that doesn't answer (10 s), and all at once
619
+ // they would hold every keytar worker.
620
+ let checks = Promise.resolve();
621
+ list.replaceChildren(...accounts.map(a => {
622
+ const uses = this.desktop.accountUses(a.id);
623
+ // Each name opens its editor: Tabby's for a profile, the plugin's form for a desktop behind an SSH host.
624
+ const used = uses.length
625
+ ? `Used by ${uses.map(u => u.profileId ? `<a data-profile="${(0, help_1.esc)(u.profileId)}">${(0, help_1.esc)(u.name)}</a>`
626
+ : u.desktopIndex !== undefined ? `<a data-desktop="${u.desktopIndex}">${(0, help_1.esc)(u.name)}</a>` : (0, help_1.esc)(u.name)).join(', ')}`
627
+ : 'Not used by a desktop yet';
628
+ const row = this.row((0, help_1.esc)(a.name), `<span>${(0, help_1.esc)((0, accounts_1.signInName)(a))}</span><br><span>${used}</span><span data-password></span>`, [
629
+ { label: 'Edit…', run: () => this.editAccount(a) },
630
+ { label: 'Remove…', danger: true, run: () => { this.desktop.confirmRemoveAccount(a.id); } },
631
+ ]);
632
+ row.querySelectorAll('[data-profile]').forEach(el => el.addEventListener('click', () => this.zone.run(() => this.editProfile(el.dataset.profile))));
633
+ row.querySelectorAll('[data-desktop]').forEach(el => el.addEventListener('click', () => this.zone.run(() => this.desktop.editDesktopIn(this.root, Number(el.dataset.desktop), true))));
634
+ // Without a password saved, the first desktop to connect asks for it and saves it.
635
+ checks = checks.then(() => row.isConnected ? this.desktop.accountHasPassword(a.id) : 'unknown').then(has => {
636
+ const note = row.querySelector('[data-password]');
637
+ if (note && has === 'no') {
638
+ note.innerHTML = '<br>No password saved: asked for on the next connection';
639
+ }
640
+ }, () => null);
641
+ return row;
642
+ }), ...accounts.length ? [] : [this.empty('None yet. Without one, each desktop asks for its account and can remember it for itself.')]);
643
+ }
644
+ /** The form under the list, for a new account or `account`. */
645
+ editAccount(account) {
646
+ const list = this.root.querySelector('[data-list=accounts]');
647
+ if (!list) {
648
+ return;
649
+ }
650
+ list.querySelector('.trd-account-form')?.remove();
651
+ const form = document.createElement('form');
652
+ form.className = 'trd-account-form';
653
+ form.autocomplete = 'off';
654
+ form.innerHTML = `
655
+ <label>Name<input class="form-control" name="name" spellcheck="false" placeholder="e.g. Domain A admin"></label>
656
+ <label>User name<input class="form-control" name="username" spellcheck="false" placeholder="e.g. adm.user, or DOMAIN\\user"></label>
657
+ <label>Domain<input class="form-control" name="domain" spellcheck="false" placeholder="Optional"></label>
658
+ <label>Password<input class="form-control" name="password" type="password" autocomplete="new-password"></label>
659
+ <div class="trd-account-error"></div>
660
+ <div class="trd-account-buttons">
661
+ <button type="button" class="btn btn-secondary btn-sm" name="cancel">Cancel</button>
662
+ <button type="submit" class="btn btn-primary btn-sm"></button>
663
+ </div>`;
664
+ const field = (name) => form.querySelector(`[name="${name}"]`);
665
+ const error = form.querySelector('.trd-account-error');
666
+ field('name').value = account?.name ?? '';
667
+ field('username').value = account?.username ?? '';
668
+ field('domain').value = account?.domain ?? '';
669
+ field('password').placeholder = account ? 'Leave empty to keep the saved one' : 'Optional; asked on the first connection';
670
+ form.querySelector('button[type=submit]').textContent = account ? 'Save' : 'Add';
671
+ const close = () => {
672
+ form.remove();
673
+ this.renderAccounts();
674
+ };
675
+ field('cancel').addEventListener('click', close);
676
+ form.addEventListener('keydown', event => {
677
+ if (event.key === 'Escape') {
678
+ close();
679
+ }
680
+ });
681
+ form.addEventListener('submit', event => {
682
+ event.preventDefault();
683
+ const username = field('username').value.trim();
684
+ const name = field('name').value.trim();
685
+ if (!username) {
686
+ error.textContent = 'Give it a user name.';
687
+ field('username').focus();
688
+ return;
689
+ }
690
+ if (this.desktop.accounts().some(a => a.id !== account?.id && a.name.toLowerCase() === (name || username).toLowerCase())) {
691
+ error.textContent = 'Another account has this name.';
692
+ field('name').focus();
693
+ return;
694
+ }
695
+ this.zone.run(async () => {
696
+ const saved = await this.desktop.saveAccount({ id: account?.id, name, username, domain: field('domain').value.trim() }, field('password').value);
697
+ close();
698
+ if (saved.keychainError) {
699
+ this.injector.get(tabby_core_1.NotificationsService).error(`The password wasn't saved: ${saved.keychainError}.`);
700
+ }
701
+ });
702
+ });
703
+ list.appendChild(form);
704
+ field(account ? 'password' : 'name').focus();
705
+ }
706
+ /** The bindings of a hotkey id in the config, each as its strokes (a chord is several). */
707
+ bindingsOf(id) {
708
+ const value = this.config.store.hotkeys?.[id];
709
+ return Array.isArray(value) ? value.map((b) => Array.isArray(b) ? b : [b]).filter((b) => b.length) : [];
710
+ }
711
+ /**
712
+ * The other hotkeys bound to the same strokes (Tabby's and this plugin's), by id. Tabby's bindings are a tree:
713
+ * `hotkeys.profile.<id>` and `hotkeys['group-selectors'].<id>` hold bindings too, and a single binding may be a
714
+ * string rather than a list, as Tabby's matcher reads them.
715
+ */
716
+ collisions(id, strokes) {
717
+ const key = strokes.join(' ');
718
+ const found = [];
719
+ const walk = (node, path) => {
720
+ if (typeof node === 'string') {
721
+ if (node === key)
722
+ found.push(path);
723
+ }
724
+ else if (Array.isArray(node)) {
725
+ if (node.some(b => (Array.isArray(b) ? b : [b]).join(' ') === key))
726
+ found.push(path);
727
+ }
728
+ else if (node && typeof node === 'object') {
729
+ for (const [k, v] of Object.entries(node)) {
730
+ walk(v, path ? `${path}.${k}` : k);
731
+ }
732
+ }
733
+ };
734
+ walk(this.config.store.hotkeys ?? {}, '');
735
+ return found.filter(other => other !== id);
736
+ }
737
+ writeBindings(id, bindings) {
738
+ this.config.store.hotkeys[id] = bindings.map(b => b.length === 1 ? b[0] : b);
739
+ this.config.save();
740
+ }
741
+ /** The plugin's shortcuts with their bindings, as Tabby's Hotkeys page shows them: chips to change, × to remove, Add… */
742
+ renderHotkeys() {
743
+ const list = this.root.querySelector('[data-list=hotkeys]');
744
+ if (!list) {
745
+ return;
746
+ }
747
+ if (!this.hotkeyNames) {
748
+ this.hotkeyNames = new Map();
749
+ this.hotkeys.getHotkeyDescriptions().then((all) => {
750
+ this.hotkeyNames = new Map(all.map(h => [h.id, h.name]));
751
+ this.renderHotkeys();
752
+ }, () => null);
753
+ }
754
+ const names = this.hotkeyNames;
755
+ list.replaceChildren(...help_1.HOTKEYS.map(h => {
756
+ const bindings = this.bindingsOf(h.id);
757
+ const row = document.createElement('div');
758
+ row.className = 'row align-items-center';
759
+ row.innerHTML = '<div class="col-7 py-2 trd-hotkey-name"></div><div class="col-5 py-2"><multi-hotkey-input></multi-hotkey-input></div>';
760
+ row.querySelector('.trd-hotkey-name').textContent = h.name;
761
+ const input = row.querySelector('multi-hotkey-input');
762
+ const conflicts = [];
763
+ bindings.forEach((strokes, i) => {
764
+ const taken = this.collisions(h.id, strokes);
765
+ conflicts.push(...taken.map(id => `${strokes.join(' ')} is also ${this.hotkeyName(id)}`));
766
+ const item = document.createElement('div');
767
+ item.className = 'item';
768
+ const body = document.createElement('div');
769
+ body.className = 'body';
770
+ body.title = 'Change';
771
+ body.append(...strokes.map(stroke => {
772
+ const el = document.createElement('div');
773
+ el.className = 'stroke';
774
+ el.innerHTML = `<span${taken.length ? ' class="duplicate"' : ''}></span>`;
775
+ el.firstElementChild.textContent = stroke;
776
+ return el;
777
+ }));
778
+ body.addEventListener('mousedown', event => {
779
+ if (event.button !== 0)
780
+ return;
781
+ this.zone.run(async () => {
782
+ const strokes = await this.captureFor(h.id);
783
+ if (strokes) {
784
+ const next = this.bindingsOf(h.id);
785
+ next[i] = strokes;
786
+ this.writeBindings(h.id, next);
787
+ }
788
+ });
789
+ });
790
+ const remove = document.createElement('div');
791
+ remove.className = 'remove';
792
+ remove.title = 'Remove';
793
+ remove.textContent = '×';
794
+ remove.addEventListener('mousedown', event => {
795
+ if (event.button !== 0)
796
+ return;
797
+ this.zone.run(() => this.writeBindings(h.id, this.bindingsOf(h.id).filter((_, n) => n !== i)));
798
+ });
799
+ item.append(body, remove);
800
+ input.appendChild(item);
801
+ });
802
+ const add = document.createElement('div');
803
+ add.className = 'add';
804
+ add.textContent = 'Add…';
805
+ add.addEventListener('click', () => this.zone.run(async () => {
806
+ const strokes = await this.captureFor(h.id);
807
+ if (strokes) {
808
+ this.writeBindings(h.id, [...this.bindingsOf(h.id), strokes]);
809
+ }
810
+ }));
811
+ input.appendChild(add);
812
+ if (conflicts.length) {
813
+ const note = document.createElement('div');
814
+ note.className = 'col-12 pb-2 trd-conflict';
815
+ note.textContent = `${conflicts.join('; ')}: only one of them would act. Change one of them.`;
816
+ row.appendChild(note);
817
+ }
818
+ return row;
819
+ }));
820
+ }
821
+ /** A hotkey's name for a conflict note: one of ours by its short name, Tabby's as Tabby's Hotkeys page lists it. */
822
+ hotkeyName(id) {
823
+ const own = help_1.HOTKEYS.find(h => h.id === id);
824
+ if (own) {
825
+ return `"${own.name}" (above or below)`;
826
+ }
827
+ // Tabby's per-profile and per-group hotkeys are keyed by the profile's or group's id.
828
+ const profile = /^profile\.(.+)$/.exec(id)?.[1];
829
+ const group = /^group-selectors\.(.+)$/.exec(id)?.[1];
830
+ if (profile) {
831
+ return `Tabby's hotkey for the profile "${(this.config.store.profiles ?? []).find((p) => p?.id === profile)?.name ?? profile}"`;
832
+ }
833
+ if (group) {
834
+ return `Tabby's hotkey for the profile group "${(this.config.store.groups ?? []).find((g) => g?.id === group)?.name ?? group}"`;
835
+ }
836
+ return `Tabby's "${this.hotkeyNames?.get(id) ?? id}"`;
837
+ }
838
+ /** Asks for a binding for one of our hotkeys, and refuses one that another hotkey has: both would fire. */
839
+ async captureFor(id) {
840
+ const strokes = await this.captureHotkey();
841
+ if (!strokes) {
842
+ return null;
843
+ }
844
+ const taken = this.collisions(id, strokes);
845
+ if (taken.length) {
846
+ this.injector.get(tabby_core_1.NotificationsService).error(`${strokes.join(' ')} is already ${taken.map(t => this.hotkeyName(t)).join(' and ')}. Choose another, or change that one first.`);
847
+ return null;
848
+ }
849
+ return strokes;
850
+ }
851
+ /**
852
+ * Tabby's "Press the key now" dialog, rebuilt in plain DOM over the page (Tabby's is not exported): the strokes
853
+ * pressed, taken a second after the last one. Resolves with them, or null on Cancel. Tabby's hotkeys are off
854
+ * meanwhile, so the keys pressed don't act.
855
+ */
856
+ captureHotkey() {
857
+ const overlay = document.createElement('div');
858
+ overlay.className = 'trd-capture';
859
+ overlay.innerHTML = `<div class="trd-form-dragbar"></div><hotkey-input-modal>
860
+ <div class="modal-header"><h5>Press the key now</h5></div>
861
+ <div class="modal-body"><div class="input"></div><div class="timeout"><div style="width: 0%"></div></div></div>
862
+ <div class="modal-footer"><button class="btn btn-primary" type="button">Cancel</button></div>
863
+ </hotkey-input-modal>`;
864
+ const input = overlay.querySelector('.input');
865
+ const bar = overlay.querySelector('.timeout div');
866
+ document.body.appendChild(overlay);
867
+ this.hotkeys.clearCurrentKeystrokes();
868
+ this.hotkeys.disable();
869
+ const strokes = [];
870
+ let last = null;
871
+ return new Promise(resolve => {
872
+ const keys = this.hotkeys.keyEvent$.subscribe(event => {
873
+ event.preventDefault();
874
+ event.stopPropagation();
875
+ });
876
+ const taken = this.hotkeys.keystroke$.subscribe(stroke => {
877
+ last = performance.now();
878
+ strokes.push(stroke);
879
+ const el = document.createElement('div');
880
+ el.className = 'stroke';
881
+ el.textContent = stroke;
882
+ input.appendChild(el);
883
+ });
884
+ const timer = window.setInterval(() => {
885
+ if (last === null)
886
+ return;
887
+ const progress = Math.min(100, (performance.now() - last) / 10);
888
+ bar.style.width = `${progress}%`;
889
+ if (progress === 100)
890
+ done(strokes);
891
+ }, 25);
892
+ const done = (result) => {
893
+ clearInterval(timer);
894
+ keys.unsubscribe();
895
+ taken.unsubscribe();
896
+ this.hotkeys.enable();
897
+ overlay.remove();
898
+ resolve(result);
899
+ };
900
+ overlay.querySelector('button').addEventListener('click', () => done(null));
901
+ });
441
902
  }
442
903
  renderCertificates() {
443
904
  const list = this.root.querySelector('[data-list=certificates]');
@@ -476,19 +937,24 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
476
937
  })();
477
938
  this.platform.setClipboard({ text: [`tabby-rdp ${version} on ${process.platform}: ${name}`, ...log].join('\n') });
478
939
  }
479
- /** Scrolls to a section (and opens a troubleshooting entry), with a short highlight. */
940
+ showTab(id) {
941
+ lastTab = TABS.some(t => t.id === id) ? id : TABS[0].id;
942
+ this.root.querySelectorAll('[data-tab]').forEach(el => el.classList.toggle('active', el.dataset.tab === lastTab));
943
+ this.root.querySelectorAll('[data-body]').forEach(el => { el.hidden = el.dataset.body !== lastTab; });
944
+ }
945
+ /** Shows the tab with a section (and opens a troubleshooting entry), with a short highlight. */
480
946
  reveal(topic, entry) {
481
947
  const target = (entry && this.root.querySelector(`details[data-entry="${CSS.escape(entry)}"]`))
482
948
  || this.root.querySelector(`[data-topic="${topic}"]`);
483
949
  if (!target) {
484
950
  return;
485
951
  }
952
+ this.showTab(TABS.find(t => t.topics.includes(topic))?.id ?? topic);
486
953
  if (target instanceof HTMLDetailsElement) {
487
954
  target.open = true;
488
955
  }
489
- // Getting started is at the top: show the page's title with it.
490
956
  ;
491
- (topic === 'start' && !entry ? this.root : target).scrollIntoView({ block: 'start', behavior: 'smooth' });
957
+ (entry ? target : this.root).scrollIntoView({ block: 'start', behavior: 'smooth' });
492
958
  target.classList.remove('trd-flash');
493
959
  void target.offsetWidth;
494
960
  target.classList.add('trd-flash');
@@ -500,26 +966,76 @@ let RemoteDesktopSettingsComponent = class RemoteDesktopSettingsComponent {
500
966
  await profiles.openNewTabForProfile(profile);
501
967
  }
502
968
  }
503
- /** Tabby's own profile editor, for a new remote desktop profile; else its Profiles page. */
504
- async newProfile() {
505
- const profiles = this.injector.get(tabby_core_1.ProfilesService);
506
- const template = (await profiles.getProfiles()).find(p => p.type === targets_1.RDP_PROFILE_TYPE && p.isTemplate);
507
- const provider = template && profiles.providerForProfile(template);
508
- let modal = null;
969
+ /** Tabby's profile editor (EditProfileModalComponent), opened in Angular's zone so that it is drawn at once; null without it. */
970
+ openModal() {
509
971
  try {
510
972
  const { NgbModal } = require('@ng-bootstrap/ng-bootstrap');
511
973
  const { EditProfileModalComponent } = require('tabby-settings');
512
- modal = provider && this.injector.get(NgbModal).open(EditProfileModalComponent, { size: 'lg' });
974
+ return this.zone.run(() => this.injector.get(NgbModal).open(EditProfileModalComponent, { size: 'lg' }));
513
975
  }
514
976
  catch {
515
- modal = null;
977
+ return null;
978
+ }
979
+ }
980
+ /** "Remove…" on a remote desktop profile: asks, then deletes it as Tabby's Profiles page would (saved account and certificate go too). */
981
+ async removeProfile(id) {
982
+ const profiles = this.injector.get(tabby_core_1.ProfilesService);
983
+ const profile = (await profiles.getProfiles()).find(p => p.id === id);
984
+ if (!profile) {
985
+ return;
986
+ }
987
+ const { response } = await this.platform.showMessageBox({
988
+ type: 'warning',
989
+ message: `Remove the profile "${profile.name}"?`,
990
+ detail: 'Its saved account and remembered certificate are forgotten too.',
991
+ buttons: ['Remove', 'Keep'],
992
+ defaultId: 1,
993
+ cancelId: 1,
994
+ });
995
+ if (response !== 0) {
996
+ return;
997
+ }
998
+ await profiles.deleteProfile(profile);
999
+ await this.config.save();
1000
+ }
1001
+ /** Tabby's own profile editor on an existing profile, as its Profiles page opens it; saves what comes back. */
1002
+ async editProfile(id) {
1003
+ const profiles = this.injector.get(tabby_core_1.ProfilesService);
1004
+ const profile = (await profiles.getProfiles()).find(p => p.id === id);
1005
+ const provider = profile && profiles.providerForProfile(profile);
1006
+ // Opened inside Angular's zone: after the await above, the zone can be lost, and a modal opened outside it isn't
1007
+ // drawn until the next click, which then lands outside it and closes it.
1008
+ const modal = provider ? this.openModal() : null;
1009
+ if (!modal || !profile || !provider) {
1010
+ this.showTabbySettings('profiles');
1011
+ return;
1012
+ }
1013
+ modal.componentInstance.partialProfile = JSON.parse(JSON.stringify(profile));
1014
+ modal.componentInstance.profileProvider = provider;
1015
+ const result = await modal.result.catch(() => null);
1016
+ if (!result) {
1017
+ return;
516
1018
  }
1019
+ result.type = provider.id;
1020
+ await profiles.writeProfile(result);
1021
+ await this.config.save();
1022
+ }
1023
+ /** Tabby's own profile editor, for a new remote desktop profile; else its Profiles page. */
1024
+ async newProfile() {
1025
+ const profiles = this.injector.get(tabby_core_1.ProfilesService);
1026
+ const template = (await profiles.getProfiles()).find(p => p.type === targets_1.RDP_PROFILE_TYPE && p.isTemplate);
1027
+ const provider = template && profiles.providerForProfile(template);
1028
+ // The group first: the editor reads its profile as soon as it opens.
1029
+ const group = provider ? await this.desktop.remoteDesktopGroup() : '';
1030
+ const modal = provider ? this.openModal() : null;
517
1031
  if (!modal || !template || !provider) {
518
1032
  this.showTabbySettings('profiles');
519
1033
  return;
520
1034
  }
521
1035
  const { id: _id, isBuiltin: _b, isTemplate: _t, ...base } = JSON.parse(JSON.stringify(template));
522
- modal.componentInstance.partialProfile = { ...base, name: '' };
1036
+ // In the plugin's group from the start, so the editor shows that group's defaults (an account, say) and a choice
1037
+ // made against them ("Ask for the account") is kept as the override it is.
1038
+ modal.componentInstance.partialProfile = { ...base, name: '', group };
523
1039
  modal.componentInstance.profileProvider = provider;
524
1040
  const result = await modal.result.catch(() => null);
525
1041
  if (!result) {
@@ -562,6 +1078,7 @@ exports.RemoteDesktopSettingsComponent = RemoteDesktopSettingsComponent = __deco
562
1078
  tabby_core_1.ConfigService,
563
1079
  tabby_core_1.PlatformService,
564
1080
  core_1.Injector,
565
- core_1.NgZone])
1081
+ core_1.NgZone,
1082
+ tabby_core_1.HotkeysService])
566
1083
  ], RemoteDesktopSettingsComponent);
567
1084
  //# sourceMappingURL=settingsPage.js.map