Skip to content

Commit c16b144

Browse files
implement search while typing, with cached tab tree (#5); auto apply panel options; add clear button (#5); introduce active result with next/prev scrolling, counter, and focus; add custom style option
1 parent 7646b46 commit c16b144

File tree

5 files changed

+250
-96
lines changed

5 files changed

+250
-96
lines changed

src/background/index.js

+101-32
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
'node_modules/web-ext-utils/browser/': { manifest, Runtime, Windows, Tabs, },
33
'node_modules/web-ext-utils/browser/messages': messages,
44
'node_modules/web-ext-utils/utils/notify': notify,
5+
'node_modules/es6lib/functional': { debounce, },
56
'common/options': options,
67
require,
78
}) => {
@@ -12,6 +13,7 @@ const TST_ID = 'treestyletab@piro.sakura.ne.jp';
1213
const TST = Object.fromEntries([
1314
'register-self',
1415
'get-tree',
16+
'scroll',
1517
'remove-tab-state', 'add-tab-state',
1618
].map(name => [
1719
name.replace(/-([a-z])/g, (_, l) => l.toUpperCase()),
@@ -27,7 +29,7 @@ async function register() {
2729
name: manifest.name,
2830
icons: manifest.icons,
2931
listeningTypes: [ 'wait-for-shutdown', ],
30-
style: [ 'hit', 'child', 'miss', ].map(
32+
style: [ 'hit', 'active', 'child', 'miss', 'custom', ].map(
3133
name => Object.values(options.result.children[name].children.styles.children).map(_=>_.value).join('\n')
3234
).join('\n') +'\n'+ options.advanced.children.hideHeader.value,
3335
subPanel: {
@@ -55,14 +57,18 @@ Runtime.onMessageExternal.addListener(onMessageExternal);
5557
register().catch(() => null); // may very well not be ready yet
5658
options.result.onAnyChange(() => register().catch(notify.error));
5759

60+
5861
/// extension logic
5962

6063
const classes = {
61-
matching: [ 'tst-search:matching', ],
64+
matching: [ 'tst-search:matching', ],
6265
hasChild: [ 'tst-search:child-matching', ],
6366
hidden: [ ],
6467
failed: [ 'tst-search:not-matching', ],
68+
active: [ 'tst-search:active', ],
6569
};
70+
let cache = null; const queueClearCache = debounce(() => { cache = null; }, 30e3);
71+
const actives = { /* [windowId]: { term: '', tabId: 0, }, */ };
6672

6773
/**
6874
* Does the actual search on the tabs in a window. Called from the panel via messaging.
@@ -76,33 +82,42 @@ const classes = {
7682
* @returns
7783
*/
7884
async function onSubmit({
79-
term, matchCase, wholeWord, regExp, fieldsPrefix = options.search.children.fieldsPrefix.value,
85+
term, matchCase, wholeWord, regExp,
86+
cached, seek,
87+
fieldsPrefix = options.search.children.fieldsPrefix.value[0],
8088
windowId = this?.windowId, // eslint-disable-line no-invalid-this
81-
}) { try {
89+
} = { }) { try {
8290
windowId || (windowId = (await Windows.getCurrent()).id);
83-
console.info('TST Search: onSubmit', windowId, this, ...arguments); // eslint-disable-line no-invalid-this
91+
debug && console.info('TST Search: onSubmit', windowId, this, ...arguments); // eslint-disable-line no-invalid-this
8492

8593
// save search flags
8694
Object.entries({ matchCase, wholeWord, regExp, }).forEach(([ name, value, ]) => {
87-
options.panel.children[name].value = value;
95+
options.panel.children[name].value = !!value;
8896
});
8997

9098
// clear previous search on empty term
91-
if (!term) { TST.removeTabState({ tabs: '*', state: [].concat(Object.values(classes)), }).catch(onError); return -1; }
99+
if (!term) {
100+
TST.removeTabState({ tabs: '*', state: [].concat(Object.values(classes)), }).catch(onError);
101+
cache = null; delete actives[windowId];
102+
return { matches: 0, cleared: true, };
103+
}
92104

93105
// pick tab properties to search
94-
const fields = [ 'title', 'url', ];
106+
const fields = options.search.children.fieldsPrefix.value[1].split(' ');
95107
if (fieldsPrefix) {
96108
const match = (/^(\w+(?:[|]\w+)*): ?(.*)/).exec(term);
97109
if (match) { fields.splice(0, Infinity, ...match[1].split('|')); term = match[2]; }
98110
}
99111

100112
// decide how to search tab properties
101-
let matches; if (regExp) {
113+
let matches; if (regExp) { try {
102114
if (wholeWord) { term = String.raw`\b(?:${term})\b`; }
103115
const exp = new RegExp(term, matchCase ? '' : 'i');
104116
matches = tab => fields.some(key => exp.test(toString(tab[key])));
105-
} else {
117+
} catch (error) {
118+
// on failing regexp while typing, return previous result
119+
if (cached && cache && cache.result) { notify.warn('Invalid RegExp', error); return cache.result; } throw error;
120+
} } else {
106121
const _map = matchCase ? _=>_ :_=>_.toLowerCase();
107122
const map = wholeWord ? _=> ' '+_map(_)+' ' :_=>_map(_);
108123
term = map(term);
@@ -111,15 +126,24 @@ async function onSubmit({
111126
function toString(prop) { return prop == null ? '' : typeof prop === 'string' ? prop : JSON.stringify(prop); }
112127

113128
// get tabs
114-
const [ nativeTabs, treeItems, ] = await Promise.all([
115-
Tabs.query({ windowId, }),
116-
TST.getTree({ window: windowId, }),
117-
]);
118-
const mergeTabs = treeItem => {
119-
treeItem.children = treeItem.children.map(mergeTabs);
120-
return { ...nativeTabs[treeItem.index], ...treeItem, };
121-
};
122-
const tabs = treeItems.map(mergeTabs);
129+
const tabs = (await (async () => {
130+
if (cached && cache && cache.windowId === windowId) {
131+
return cache.tabs;
132+
}
133+
const [ nativeTabs, treeItems, ] = await Promise.all([
134+
Tabs.query({ windowId, }),
135+
TST.getTree({ windowId, }),
136+
]);
137+
const byId = new Map;
138+
const mergeTabs = (treeItem, parent) => {
139+
treeItem.children = treeItem.children.map(tab => mergeTabs(tab, treeItem.id));
140+
const tab = { ...nativeTabs[treeItem.index], ...treeItem, parent, };
141+
byId.set(tab.id, tab); return tab;
142+
};
143+
const tabs = treeItems.map(tab => mergeTabs(tab, -1)); tabs.byId = byId;
144+
cache = { windowId, tabs, }; queueClearCache();
145+
return tabs;
146+
})());
123147

124148
// find search results
125149
const result = {
@@ -136,22 +160,67 @@ async function onSubmit({
136160
} !ret && result.failed.add(tab); return ret;
137161
}).some(_=>_); })(tabs);
138162

163+
const matching = Array.from(result.matching);
164+
const active = actives[windowId] || (actives[windowId] = { });
165+
if (matching.length === 0) { active.tabId = -1; active.term = term; }
166+
else if (!tabs.byId.has(active.tabId)) { active.tabId = matching[0].id; active.term = term; }
167+
else if (result.matching.has(tabs.byId.get(active.tabId))) { void 0; }
168+
else {
169+
if (matching.length === 1) { active.tabId = matching[0]; } const prev = tabs.byId.get(active.tabId);
170+
active.tabId = (matching.find(tab => tab.index > prev.index) || matching[0]).id;
171+
}
172+
if (typeof seek === 'boolean') { if (matching.length > 1) {
173+
const current = matching.indexOf(tabs.byId.get(active.tabId));
174+
if (seek) { active.tabId = matching[current + 1 < matching.length ? current + 1 : 0].id; }
175+
else { active.tabId = matching[current - 1 >= 0 ? current - 1 : matching.length - 1].id; }
176+
} }
177+
139178
// apply findings
140-
(await TST.removeTabState({ tabs: '*', state: [].concat(Object.values(classes)), }).catch(error => void onError(error)));
141-
(await Promise.all(Object.keys(result).map(
142-
state => classes[state].length && result[state].size
143-
&& TST.addTabState({ tabs: Array.from(result[state], _=>_.id), state: classes[state], })
144-
)));
179+
(await Promise.all([
180+
TST.removeTabState({
181+
tabs: Array.from(tabs.byId.keys()), // Explicitly pass the IDs, to ensure consistent runtime with the other calls. The IDs have either just been queried, or wrer the ones that the classes were applied to.
182+
state: [].concat(Object.values(classes)),
183+
}).catch(error => void onError(error)),
184+
...Object.keys(result).map(
185+
state => classes[state].length && result[state].size
186+
&& TST.addTabState({ tabs: Array.from(result[state], _=>_.id), state: classes[state], })
187+
),
188+
typeof seek === 'boolean' && active.tabId >= 0 && TST.scroll({ tab: active.tabId, }),
189+
active.tabId >= 0 && TST.addTabState({ tabs: [ active.tabId, ], state: classes.active, }),
190+
]));
191+
192+
return ((cache || { }).result = { matches: result.matching.size, index: matching.indexOf(tabs.byId.get(active.tabId)), });
193+
194+
} catch (error) { notify.error('Search failed!', error); return { matches: 0, failed: true, }; } }
195+
messages.addHandler(onSubmit);
145196

146-
return result.matching.size;
147197

148-
} catch (error) { notify.error('Search failed!', error); return -2; } }
149-
messages.addHandler(onSubmit);
150-
messages.addHandler(function getOptions() {
151-
return Object.fromEntries(Object.entries(options.panel.children).map(pair => {
152-
pair[1] = pair[1].value; return pair;
153-
}));
154-
});
198+
messages.addHandler(async function focusActiveTab({
199+
windowId = this?.windowId, // eslint-disable-line no-invalid-this
200+
} = { }) { try {
201+
windowId || (windowId = (await Windows.getCurrent()).id);
202+
debug && console.info('TST Search: focusActiveTab', windowId, this, ...arguments); // eslint-disable-line no-invalid-this
203+
const tabId = actives[windowId] && actives[windowId].tabId;
204+
(await Tabs.update(tabId, { active: true, }));
205+
} catch (error) { notify.error('Tab Focus Failed', error); } });
206+
207+
208+
{ // let panel instances know about `options.panel.children.*.value`
209+
function getOptions() {
210+
return Object.fromEntries(Object.entries(options.panel.children).map(pair => {
211+
pair[1] = pair[1].value; return pair;
212+
}));
213+
}
214+
const callbacks = new Set;
215+
options.panel.onAnyChange(() => {
216+
const opts = getOptions();
217+
callbacks.forEach(_=>_(opts));
218+
callbacks.clear();
219+
});
220+
messages.addHandlers({ getOptions, awaitOptions() {
221+
return new Promise(resolve => callbacks.add(resolve));
222+
}, });
223+
}
155224

156225

157226
Object.assign(global, { // for debugging

src/common/options.js

+47-28
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ const model = {
1616
panel: {
1717
title: 'Search Box Options',
1818
expanded: true,
19-
description: `<small>These are only applied when the search box is newly loaded in TST's sidebar. Click <code>(re-)register</code> above to force that.</small>`,
2019
default: true, children: {
2120
matchCase: {
2221
default: false,
@@ -30,21 +29,29 @@ const model = {
3029
default: false,
3130
input: { type: 'boolean', suffix: `<details><summary>Regular Expression:</summary>Search by (JavaScript) regular expression instead of plain string. If you don't know what this is, then you probably don't want it.</details>`, },
3231
},
33-
hideOptions: {
32+
hideFlags: {
3433
default: false,
35-
input: { type: 'boolean', suffix: `<details><summary>Hide Search Options:</summary>Don't show the buttons for the three search options above in the search box. Note that what is selected on this page will still apply to the search.`, },
34+
input: { type: 'boolean', suffix: `<details><summary>Hide Search Option Buttons:</summary>Don't show the buttons for the three search options above in the search box. Note that what is selected on this page will still apply to the search.`, },
35+
},
36+
hideClear: {
37+
default: false,
38+
input: { type: 'boolean', suffix: `Hide "Clear Search" Button`, },
39+
},
40+
hideCount: {
41+
default: false,
42+
input: { type: 'boolean', suffix: `Hide Result Counter/Status`, },
3643
},
3744
darkTheme: {
3845
default: null,
3946
input: { type: 'menulist', options: [
4047
{ value: null, label: `auto`, },
4148
{ value: false, label: `light`, },
4249
{ value: true, label: `dark`, },
43-
], prefix: `Color theme:`, },
50+
], prefix: `Color Theme:`, },
4451
},
4552
placeholder: {
4653
default: 'Search ...',
47-
input: { type: 'string', prefix: 'Search box placeholder:', },
54+
input: { type: 'string', prefix: 'Search Box Placeholder:', },
4855
},
4956
},
5057
},
@@ -57,9 +64,6 @@ const model = {
5764
title: 'Matches/Hits',
5865
description: `Any tabs that themselves match the search.`,
5966
default: true, children: {
60-
classes: classes({
61-
default: '',
62-
}),
6367
styles: styles([
6468
[ 'bold', true, 'Bold Text', String.raw`
6569
.tab:not(.pinned).tst-search\:matching .label { font-weight: bold; }
@@ -70,13 +74,24 @@ const model = {
7074
]),
7175
},
7276
},
77+
active: {
78+
title: 'Active Result',
79+
description: `The active search result, which can be scrolled through with <code>Enter</code> and <code>Shift</code>+<code>Enter</code>.`,
80+
default: true, children: {
81+
styles: styles([
82+
[ 'red', true, 'Red Text', String.raw`
83+
.tab:not(.pinned).tst-search\:active .label { color: red; }
84+
`, ],
85+
[ 'bold', false, 'Bold Text', String.raw`
86+
.tab:not(.pinned).tst-search\:active .label { font-weight: bold; }
87+
`, ],
88+
]),
89+
},
90+
},
7391
child: {
7492
title: 'With Matching Children',
7593
description: `Any tabs with children that match the search.`,
7694
default: true, children: {
77-
classes: classes({
78-
default: '',
79-
}),
8095
styles: styles([
8196
[ 'red', false, 'Red Text', String.raw`
8297
.tab:not(.pinned).tst-search\:child-matching .label { color: red; }
@@ -91,9 +106,6 @@ const model = {
91106
title: 'Other/Misses',
92107
description: `Any tab that neither matches not has matching children.`,
93108
default: true, children: {
94-
classes: classes({
95-
default: '',
96-
}),
97109
styles: styles([
98110
[ 'shrink', true, 'Shrink Height', String.raw`
99111
.tab:not(.pinned).collapsed:where(.tst-search\:matching, .tst-search\:child-matches, .tst-search\:not-matching) {
@@ -111,6 +123,16 @@ const model = {
111123
]),
112124
},
113125
},
126+
custom: {
127+
title: 'Custom Styles',
128+
description: String.raw`Custom CSS to apply to the TST sidebar.<br>
129+
${manifest.name} sets the CSS classes <code>tst-search:matching</code>, <code>tst-search:active</code>, <code>tst-search:child-matching</code>, and <code>tst-search:not-matching</code> on tabs in the four result categories above, respectively.<br>
130+
For example: <code>.tab:not(.pinned).tst-search\:active .label { color: red; }</code> `,
131+
expanded: false,
132+
default: true, children: { styles: { default: true, children: { raw: {
133+
default: '', input: { type: 'code', },
134+
}, }, }, },
135+
},
114136
},
115137
},
116138
search: {
@@ -129,11 +151,19 @@ const model = {
129151
<li>tabs with SVG favicons: <code>favIconUrl: [./]svg\b</code> (<code>.*</code>)</li>
130152
<li>tabs by container (ID): <code>cookieStoreId: firefox-container-1</code></li>
131153
<li>loaded tabs: <code>discarded: false</code></li>
132-
<li>tabs by ID: <code>id: ^42$</code> (<code>.*</code>)</li>
154+
<li>tabs by ID: <code>id: 42</code> (<code>wrd</code>)</li>
155+
<li>a tab and its direct children: <code>id|parent: 42</code> (<code>wrd</code>)</li>
133156
</ul>
134157
</details>`,
135-
default: false,
136-
input: { type: 'boolean', suffix: `enable field prefixes`, },
158+
default: [ [ false, 'title url', ], ],
159+
input: [
160+
{ type: 'boolean', suffix: `Enable Field Prefixes<br>`, },
161+
{ type: 'string', prefix: `Default Properties:`, },
162+
],
163+
restrict: [
164+
{ type: 'boolean', },
165+
{ type: 'string', match: { exp: /^\w+(?:[ ]\w+)*$/, }, },
166+
],
137167
},
138168
},
139169
},
@@ -165,19 +195,8 @@ const model = {
165195

166196
return (await new Options({ model, storage, prefix: 'options', })).children;
167197

168-
function classes(options) { return null && {
169-
title: 'Additional Tab State',
170-
description: `For interoperability with other TST extensions. Space separated list of states to assign to these tabs.`,
171-
restrict: { match: {
172-
exp: (/^(:?[\w-]+(?:\S+[\w-]+)*)?$/i),
173-
message: `Must be a space separated list of words (allowing <code>-</code> and <code>_</code>)`,
174-
}, },
175-
input: { type: 'string', },
176-
...options, // default,
177-
}; }
178198

179199
function styles(snippets) { return {
180-
title: 'Styles',
181200
default: true, children: Object.fromEntries(snippets.map(([ name, active, description, css, ]) => [ name, {
182201
default: active ? css : '',
183202
input: { type: 'boolInt', suffix: description, off: '', on: css, },

0 commit comments

Comments
 (0)