Skip to content

Commit a4ff304

Browse files
add global search hotkey functionality (#2)
1 parent c24773f commit a4ff304

File tree

10 files changed

+226
-101
lines changed

10 files changed

+226
-101
lines changed

README.md

+27-9
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11

22
# TST Tab Search -- filter Tree Style Tab's sidebar
33

4+
<!-- <sub><a href="https://addons.mozilla.org/firefox/addon/tst-search/"><img src="./resources/get-ff-ext.png" width="86" height="30"></a></sub> -->
5+
46
This is an extension for the browser extension [Tree Style Tabs](https://github.com/piroor/treestyletab#readme) (TST). It adds a search box at the bottom of TST's sidebar, allowing to search the titles and URLs (or whatever) of the tabs in the current window/sidebar, optionally case sensitive, as whole word, or by regular expression.
57
Matching tabs will be highlighted in the tree, and/or non-matches will be suppressed (see extension preferences).
68
Should the search bar not show up after installing this extension, then have a look at `about:addons` > "Extensions" > "TST Tab Search" > "Preferences".
79

8-
![Searching for Cats](./resources/screenshot.png)
10+
<img alt="Searching for Cats" src="./resources/screenshot.png" width="440px">
911

1012
Thats pretty much all there is to say.
1113
Many thanks to TST's author [piroor](https://github.com/piroor), who has not only developed TST as a great standalone extension, but also designed a very good API for other extensions to interact and integrate with TST. With that, writing the initial version of this extension from scratch took only about two long afternoons.
@@ -15,11 +17,6 @@ Many thanks to TST's author [piroor](https://github.com/piroor), who has not onl
1517
- "Access to browser tabs": Get titles and URLs of tabs to be searched.
1618
- "Display notifications to you": Notify when something went wrong, or right.
1719

18-
<b>Currently Impossible Features</b>:
19-
20-
* set focus to search bar via hotkey (e.g. on Ctrl+Shift+F)
21-
* blocked by https://bugzilla.mozilla.org/show_bug.cgi?id=1502713
22-
2320
<!-- NOTE: AMO keeps line breaks within paragraphs ... -->
2421

2522

@@ -31,11 +28,32 @@ I am happy to receive feedback or contributions on this. This is a (currently sh
3128
* update the screenshot with active tab and counter (and/or add more screenshots)
3229

3330

34-
## Development builds -- [![](https://ci.appveyor.com/api/projects/status/github/NiklasGollenstede/tst-search?svg=true)](https://ci.appveyor.com/project/NiklasGollenstede/tst-search)
31+
# TODO
32+
33+
* for/before AMO listing (/the README)
34+
* add disclaimer: this is a fairly young extension
35+
* state: does not mess with FF tabs, only affects how TST displays them (and is thus pretty safe to use, even in beta)
36+
* write the short description / summary
37+
* maybe rework the entire listing:
38+
* short description
39+
* features
40+
* more screenshots
41+
* clear prompt to review the settings
42+
* privacy policy
43+
* currently does not send anything
44+
* except on dev build the update poll once a day, which isn't analyzed at all, and can be disabled in FF
45+
* will never send any tab content, URLs or search terms
46+
* *maybe*: It is possible, yet unlikely, that ... . But should there would be a clear notification of any changes in that direction, before they would take effect.
47+
* add (badge) link to AMO listing
48+
49+
50+
## Development builds -- <sub>[![](https://ci.appveyor.com/api/projects/status/github/NiklasGollenstede/tst-search?svg=true)](https://ci.appveyor.com/project/NiklasGollenstede/tst-search)</sub>
3551

3652
Development builds are automatically created on every commit with [appveyor](https://ci.appveyor.com/project/NiklasGollenstede/tst-search/history) and [released](https://github.com/NiklasGollenstede/tst-search/releases) on GitHub.\
37-
These builds use a different id (`-dev` suffix), so they are installed as an additional extension and do not replace the release version. This means that:
38-
* you probably want to disable the release version while the development version is active
53+
To install them, go to [releases](https://github.com/NiklasGollenstede/tst-search/releases), under `Latest release` > "Assets" click `*-an.fx.xpi`, click allow and install; they will then update automatically.
54+
55+
These builds use a different id (`-dev` suffix), so they are installed as a separate extension (from the release version) and do not replace it upon installation. This means that:
56+
* you probably want to disable the release version while the development version is active, and vice versa
3957
* any options set are managed individually (so pre-release versions can't mess with your settings)
4058
* they never update to release versions, but
4159
* they update themselves to the latest development version (once a day, or when clicking `about:addons` > ⚙ > "Check for Updates")

src/background/index.js

+68-31
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
(function(global) { 'use strict'; define(async ({ // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
2-
'node_modules/web-ext-utils/browser/': { manifest, Runtime, Windows, Tabs, },
2+
'node_modules/web-ext-utils/browser/': { manifest, Commands, BrowserAction, Runtime, Tabs, Windows, },
33
'node_modules/web-ext-utils/browser/messages': messages,
4+
'node_modules/web-ext-utils/loader/views': { getViews, },
45
'node_modules/web-ext-utils/utils/notify': notify,
56
'node_modules/es6lib/functional': { debounce, },
67
'common/options': options,
8+
'./util': { updateCommand, },
79
tstApi,
810
require,
911
}) => {
@@ -63,7 +65,7 @@ const classes = {
6365
};
6466
/**@type{{ tabs: Tab[] & { byId: Map<number, Tab>, }, windowId: number, } | null}*/ let cache = null;
6567
/**@type{() => undefined}*/ const queueClearCache = debounce(() => { cache = null; }, 30e3);
66-
/**@type{Record<number, { tabId: number, }>}*/ const actives = { __proto__: null, };
68+
/**@type{Record<number, { tabId: number, term: string, }>}*/ const windowStates = { __proto__: null, };
6769

6870
/**
6971
* Does the actual search on the tabs in a window. Called from the panel via messaging.
@@ -83,19 +85,19 @@ const classes = {
8385
* and making it the target of `focusActiveTab`. The active tab's ID is saved per window,
8486
* and maintained if it still matches the search, otherwise the next matching tab is selected.
8587
* Then iff `.seek` is `true` the next next, iff `false` the previous matching tab is activated.
86-
* @returns { { matches: number, } & ({ index: number, } | { cleared: true, } | { failed: true, }) } The number of search `matches`, plus either:
88+
* @returns { Promise<{ matches: number, } & ({ index: number, } | { cleared: true, } | { failed: true, })> } The number of search `matches`, plus either:
8789
* * if `term` was empty: `cleared: true`;
8890
* * on search success: the active tab's index (or `-1`);
8991
* * or on any failure: `failed: true`.
9092
*/
9193
async function doSearch({
92-
windowId = this?.windowId, // eslint-disable-line no-invalid-this
94+
windowId = this?.windowId || -1, // eslint-disable-line no-invalid-this
9395
term = '', matchCase = false, wholeWord = false, regExp = false,
9496
fieldsPrefix = !!options.search.children.fieldsPrefix.value?.[0],
9597
fieldsDefault = options.search.children.fieldsPrefix.value?.[1]?.split?.(' ') || [ 'title', 'url', ],
9698
cached = false, seek = undefined,
9799
} = { }) { try {
98-
windowId != null || (windowId = (await Windows.getCurrent()).id);
100+
(windowId != null && windowId !== -1) || (windowId = (await Windows.getCurrent()).id);
99101
debug && console.info('TST Search: doSearch', windowId, this, ...arguments); // eslint-disable-line no-invalid-this
100102

101103
// save search flags
@@ -106,9 +108,9 @@ async function doSearch({
106108
// clear previous search on empty term
107109
if (!term) {
108110
TST.removeTabState({ tabs: '*', state: [ ].concat(...Object.values(classes)), }).catch(onTstError);
109-
cache = null; delete actives[windowId];
111+
cache = null; delete windowStates[windowId];
110112
return { matches: 0, cleared: true, };
111-
}
113+
} term += '';
112114

113115
// pick tab properties to search
114116
const fields = fieldsDefault; if (fieldsPrefix) {
@@ -169,18 +171,18 @@ async function doSearch({
169171

170172
// determine active tab
171173
const matching = Array.from(result.matching);
172-
const active = actives[windowId] || (actives[windowId] = { tabId: -1, });
173-
if (matching.length === 0) { active.tabId = -1; }
174-
else if (!tabs.byId.has(active.tabId)) { active.tabId = matching[0].id; }
175-
else if (result.matching.has(tabs.byId.get(active.tabId))) { void 0; }
176-
else {
177-
if (matching.length === 1) { active.tabId = matching[0]; } const prev = tabs.byId.get(active.tabId);
178-
active.tabId = (matching.find(tab => tab.index > prev.index) || matching[0]).id;
174+
const state = windowStates[windowId] || (windowStates[windowId] = { tabId: -1, });
175+
state.term = term;
176+
if (matching.length === 0) { state.tabId = -1; }
177+
else if (!tabs.byId.get(state.tabId)) { state.tabId = matching[0].id; }
178+
else if (!result.matching.has(tabs.byId.get(state.tabId))) {
179+
const prev = tabs.byId.get(state.tabId);
180+
state.tabId = (matching.find(tab => tab.index > prev.index) || matching[0]).id;
179181
}
180182
if (typeof seek === 'boolean') { if (matching.length > 1) {
181-
const current = matching.indexOf(tabs.byId.get(active.tabId));
182-
if (seek) { active.tabId = matching[current + 1 < matching.length ? current + 1 : 0].id; }
183-
else { active.tabId = matching[current - 1 >= 0 ? current - 1 : matching.length - 1].id; }
183+
const current = matching.indexOf(tabs.byId.get(state.tabId));
184+
if (seek) { state.tabId = matching[current + 1 < matching.length ? current + 1 : 0].id; }
185+
else { state.tabId = matching[current - 1 >= 0 ? current - 1 : matching.length - 1].id; }
184186
} }
185187

186188
// apply tab states
@@ -193,29 +195,36 @@ async function doSearch({
193195
state => classes[state].length && result[state].size
194196
&& TST.addTabState({ tabs: Array.from(result[state], _=>_.id), state: classes[state], })
195197
),
196-
typeof seek === 'boolean' && active.tabId >= 0 && TST.scroll({ tab: active.tabId, }).catch(onTstError), // This throws (if the target tab is collapsed?). Also, collapsed tabs aren't scrolled to (the parent).
197-
active.tabId >= 0 && TST.addTabState({ tabs: [ active.tabId, ], state: classes.active, }),
198+
typeof seek === 'boolean' && state.tabId >= 0 && TST.scroll({ tab: state.tabId, }).catch(onTstError), // This throws (if the target tab is collapsed?). Also, collapsed tabs aren't scrolled to (the parent).
199+
state.tabId >= 0 && TST.addTabState({ tabs: [ state.tabId, ], state: classes.active, }),
198200
]));
199201

200-
return ((cache || { }).result = { matches: result.matching.size, index: matching.indexOf(tabs.byId.get(active.tabId)), });
202+
return ((cache || { }).result = { matches: result.matching.size, index: matching.indexOf(tabs.byId.get(state.tabId)), });
201203

202204
} catch (error) { notify.error('Search failed!', error); return { matches: 0, failed: true, }; } }
203-
messages.addHandler(doSearch);
205+
206+
207+
async function getTerm({
208+
windowId = this?.windowId || -1, // eslint-disable-line no-invalid-this
209+
} = { }) { try {
210+
windowId != null && windowId !== -1 || (windowId = (await Windows.getCurrent()).id);
211+
debug && console.info('TST Search: getTerm', windowId, this, ...arguments); // eslint-disable-line no-invalid-this
212+
return windowStates[windowId]?.term;
213+
} catch (error) { notify.error('Tab Focus Failed', error); } return null; }
204214

205215

206216
async function focusActiveTab({
207-
windowId = this?.windowId, // eslint-disable-line no-invalid-this
217+
windowId = this?.windowId || -1, // eslint-disable-line no-invalid-this
208218
} = { }) { try {
209-
windowId != null || (windowId = (await Windows.getCurrent()).id);
219+
windowId != null && windowId !== -1 || (windowId = (await Windows.getCurrent()).id);
210220
debug && console.info('TST Search: focusActiveTab', windowId, this, ...arguments); // eslint-disable-line no-invalid-this
211-
const tabId = actives[windowId] && actives[windowId].tabId;
221+
const tabId = windowStates[windowId]?.tabId;
212222
tabId >= 0 && (await Tabs.update(tabId, { active: true, }));
213223
return tabId;
214224
} catch (error) { notify.error('Tab Focus Failed', error); } return null; }
215-
messages.addHandler(focusActiveTab);
216225

217226

218-
{ // let panel instances know about `options.panel.children.*.value`
227+
const { getOptions, awaitOptions, } = (() => { // let panel instances know about `options.panel.children.*.value`
219228
function getOptions() {
220229
return Object.fromEntries(Object.entries(options.panel.children).map(pair => {
221230
pair[1] = pair[1].value; return pair;
@@ -227,19 +236,47 @@ messages.addHandler(focusActiveTab);
227236
callbacks.forEach(_=>_(opts));
228237
callbacks.clear();
229238
});
230-
messages.addHandlers({ getOptions, awaitOptions() {
239+
return { async getOptions() {
240+
return getOptions();
241+
}, awaitOptions() {
231242
return new Promise(resolve => callbacks.add(resolve));
232-
}, });
233-
}
243+
}, };
244+
})();
245+
246+
247+
const RPC = { doSearch, getTerm, focusActiveTab, getOptions, awaitOptions, };
248+
messages.addHandlers(RPC);
249+
250+
251+
Commands.onCommand.addListener(async function onCommand(command) { try { {
252+
debug && console.info('TST: onCommand', command);
253+
} switch (command.replace(/_\d$/, '')) {
254+
case 'globalFocusKey': {
255+
// can't focus sidebar, so open/focus the browserAction popup
256+
/**@type{Window}*/ const panel = getViews().find(_=>_.name === 'panel')?.view;
257+
/**@type{HTMLInputElement}*/ const input = panel?.document.querySelector('#term');
258+
if (input) {
259+
if (input.matches(':focus')) {
260+
// can't listen to ESC press, so clear on redundant focus command
261+
input.value = ''; input.dispatchEvent(new panel.Event('input'));
262+
} else {
263+
panel.close(); (await BrowserAction.openPopup()); // focus
264+
}
265+
} else {
266+
(await BrowserAction.openPopup());
267+
}
268+
} break;
269+
} } catch (error) { notify.error('Command Failed', error); } });
270+
options.search.children.globalFocusKey.whenChange(values => updateCommand('globalFocusKey', 1, values));
234271

235272

236273
Object.assign(global, { // for debugging
237274
options,
238-
TST, register, unregister,
275+
TST, register, unregister, RPC,
239276
doSearch, focusActiveTab,
240277
Browser: require('node_modules/web-ext-utils/browser/'),
241278
});
242279

243-
return { TST, register, unregister, };
280+
return { TST, register, unregister, RPC, };
244281

245282
}); })(this);

src/background/tst-api.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ function tstAPI({ getManifest, methods = [ ], events = { __proto__: null, }, onE
4343
name.replace(/-([a-z])/g, (_, l) => l.toUpperCase()),
4444
(options) => {
4545
API.debug && console.info(ownName +': sendMessageExternal', TST_ID, { ...options, type: name, });
46-
return global.browser.runtime.sendMessage(TST_ID, { ...options, type: name, });
46+
return global.browser.runtime.sendMessage(TST_ID, { ...options, type: name, }); // It would be nice if connection errors were distinguishable from errors on TST's side ...
4747
},
4848
]));
4949

src/background/util.js

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
(function(global) { 'use strict'; const factory = function util(exports) { // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
2+
3+
4+
async function updateCommand(name, count, values) {
5+
const commands = (await global.browser.commands.getAll());
6+
for (let i = 0; i < count; ++i) { // `value.length` may be smaller than `count`
7+
const id = name + (i ? '_'+ i : ''), command = commands.find(_=>_.name === id);
8+
command.shortcut = values[i] || null;
9+
if (command.shortcut) { try {
10+
(await global.browser.commands.update(command));
11+
} catch (error) {
12+
global.browser.commands.reset(id); throw error;
13+
} } else {
14+
global.browser.commands.reset(id); // can't remove, so reset instead, which removes if default is unset
15+
}
16+
}
17+
}
18+
19+
return { updateCommand, };
20+
21+
}; if (typeof define === 'function' /* global define */ && define.amd) { define([ 'exports', ], factory); } else { const exp = { }, result = factory(exp) || exp; global[factory.name] = result; } })(this);

src/common/options.js

+8
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@ const model = {
140140
expanded: true,
141141
description: ``,
142142
default: true, children: {
143+
globalFocusKey: {
144+
title: 'Focus Search Bar Hotkey',
145+
description: `Browser-wide hotkey to focus the the search bar.<br>
146+
NOTE: Firefox currently does not allow extensions to focus (elements in) their sidebars (see <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1502713">Firefox bug 1502713</a>). So for now, this extension instead opens a small panel at the top of the window with a copy of the search bar. Since <code>Esc</code> keypresses are also unavailable while a panel is open, pressing this hotkey clears the search, when pressed while the panel has focus.`,
147+
default: 'Ctrl + Shift + F',
148+
minLength: 0, maxLength: 1,
149+
input: { type: 'command', default: 'Ctrl + Shift + F', },
150+
},
143151
fieldsPrefix: {
144152
title: 'Tab Property Prefixes',
145153
description: String.raw`By default ${manifest.name} will look for the search term, according to the flags set, in the tab's title (the text displayed in the tooltip when holding the mouse cursor the tab) and the URL (the web address displayed at the center top of the window when the tab is active). This should do for most users most of the time.<br>

0 commit comments

Comments
 (0)