feat(client): added jump feature (type the name of any resource, and quickly jump to it)

This commit is contained in:
Will Moss
2024-05-03 04:29:01 +02:00
parent ccdf2e5d54
commit 92fea15cb7
3 changed files with 371 additions and 14 deletions

View File

@@ -511,6 +511,59 @@ html {
}
}
&.for-jump {
width: 630px;
@media screen and (max-width: @width-mobile) {
width: 95%;
}
.tab-content {
.jump-input-wrapper {
padding-left: 8px;
input {
border: 0;
background: transparent;
color: var(--color-terminal-base);
caret-color: var(--color-terminal-base);
outline: 0;
width: 90%;
}
}
.jump-results {
padding-left: 8px;
margin-top: 8px;
max-height: 185px;
overflow: auto;
.no-result-message {
color: var(--color-terminal-warning);
}
.jump-result {
padding-left: 0;
// Tab
span:nth-child(1) {
color: var(--color-terminal-accent);
margin-right: 4px;
}
// Resource name
span:nth-child(2) {
margin-left: 4px;
}
span {
pointer-events: none;
}
}
}
}
}
&.for-message[data-category='authentication'] {
width: 435px;
}

View File

@@ -105,6 +105,21 @@
''
);
/**
* Turn a given method into a debounced version of it
* @param {Function} fn
* @param {int} delay
*/
const debounce = (fn, delay) => {
let timeoutId = null;
return (...args) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
fn(...args);
}, delay);
};
};
// === Handy HTML-querying methods
/**
@@ -190,6 +205,11 @@
*/
const hgetPromptInput = () => q('#prompt-input');
/**
* @returns {HTMLElement}
*/
const hgetJumpInput = () => q('#jump-input');
/**
* @returns {HTMLElement}
*/
@@ -732,6 +752,11 @@
<span class="cell">O </span>
<span class="cell">show overview</span>
</div>
<div class="row is-not-interactive">
<span class="cell">J </span>
<span class="cell">jump to any resource</span>
</div>
<div class="row is-not-interactive"></div>
<div class="row is-not-interactive">
<span class="cell">S </span>
<span class="cell">open system shell</span>
@@ -744,6 +769,7 @@
<span class="cell">G </span>
<span class="cell">open project on Github</span>
</div>
<div class="row is-not-interactive"></div>
<div class="row is-not-interactive">
<span class="cell">T </span>
<span class="cell">open theme picker</span>
@@ -874,6 +900,53 @@
</div>
`;
/**
* @param {Jump} jump
* @returns {string}
*/
const renderJump = (jump) => `
<div class="popup for-jump">
<div class="tab is-active">
<span class="tab-title">
Jump
</span>
<div class="tab-content">
<div class="jump-input-wrapper">
<input
id="jump-input"
type="text"
placeholder="Type the name of a resource"
value="${jump.search ? jump.search : ''}"
/>
</div>
<div class="jump-results">
${
!jump.search
? `<p><i>Start typing, and results will appear</i></p>`
: jump.results.length === 0
? `<p class="no-result-message"><i>No resource found</i></p>`
: jump.results
.map(
(r) =>
`
<div
class="jump-result row"
data-jump="${r.ParentKey}.${r.ID || r.Name}"
>
<span>${r.Parent}</span>
&gt;
<span>${r.Name}</span>
</div>
`
)
.join('')
}
</div>
</div>
</div>
</div>
`;
/**
* Main rendering function, responsible for updating the DOM
* from scratch, using the supplied _state argument
@@ -960,6 +1033,8 @@
// 4.6. Popup - Overview
else if (_state.overview.isEnabled)
html = renderOverview(_state.overview);
// 4.7. Popup - Jump
else if (_state.jump.isEnabled) html = renderJump(_state.jump);
hgetPopupContainer().innerHTML = html;
}
@@ -1078,6 +1153,18 @@
_state.popup,
_state.navigation.currentMenuRow
).classList.add('is-active');
// 6.3.3. Set focus on row - Jump
if (_state.jump.isEnabled && _state.jump.results.length > 0) {
hgetPopupRow(
_state.popup,
_state.navigation.currentMenuRow
).classList.add('is-active');
hgetPopupRow(
_state.popup,
_state.navigation.currentMenuRow
).scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
}
if (!_state.isFullyEmpty) {
@@ -1121,6 +1208,18 @@
hgetTab(_state.navigation.previousTab).classList.add('is-current');
}
// 6.8. Set focus on jump input
if (_state.jump.isEnabled) {
const jumpInput = hgetJumpInput();
jumpInput.focus();
// 6.8.1. Focus end of input
window.requestAnimationFrame(() => {
jumpInput.selectionStart = jumpInput.selectionEnd =
jumpInput.value.length;
});
}
// 7. Set helper
if (!_state.search.isEnabled)
hgetHelper(_state.helper).classList.add('is-active');
@@ -1187,6 +1286,8 @@
hgetMobileControl('agent').classList.add('is-active');
if (_state.communication.availableHosts.length > 0)
hgetMobileControl('host').classList.add('is-active');
hgetMobileControl('overview').classList.add('is-active');
hgetMobileControl('jump').classList.add('is-active');
}
// 12. Apply extra user settings if any
@@ -1279,6 +1380,7 @@
'host',
'parameters',
'overview',
'jump',
].includes(state.popup)
);
},
@@ -1348,7 +1450,7 @@
helper: 'default',
/**
* @type {"menu"|"bulk"|"prompt"|"message"|"tty"|"help"|"overview"|"theme"|"agent"|'host'|'parameters'}
* @type {"menu"|"bulk"|"prompt"|"message"|"tty"|"help"|"overview"|"theme"|"agent"|"host"|"parameters"|"jump"}
*/
popup: null,
@@ -1551,6 +1653,22 @@
previousRows: [],
},
/**
* @typedef {object} Jump
* @property {boolean} isEnabled
* @property {string} search
* @property {Array<Row>} results
*/
/**
* @type {Jump}
*/
jump: {
isEnabled: false,
search: null,
results: [],
},
_delays: {
/**
* @type {number}
@@ -1749,7 +1867,7 @@
const isPrivate = cmdString[0] === '_';
const isAllowed = isPrivate ? true : cmdAllowed(cmdString);
// console.log(cmdString, isAllowed);
console.log(cmdString, isAllowed);
if (!isAllowed) return;
@@ -1758,6 +1876,10 @@
renderApp(state);
};
const debouncedCmdRun = debounce((cmd, ...args) => {
cmdRun(cmd, ...args);
}, state._delays.default);
// === Commands
const cmds = {
@@ -1860,6 +1982,16 @@
cmdRun(cmds._clearPopup);
},
/**
* Private - Clear jump
*/
_clearJump: function () {
state.jump.isEnabled = false;
state.jump.search = null;
state.jump.results = [];
cmdRun(cmds._clearPopup);
},
/**
* Private - Show a popup
* @param {string} key
@@ -2205,7 +2337,7 @@
},
/**
* Private - Perform search
* Private - Perform search (local one)
* @param {boolean} resetOriginalRows
*/
_performSearch: function (resetOriginalRows = false) {
@@ -2266,6 +2398,32 @@
}
},
_performJumpSearch: function () {
const { search } = state.jump;
if (!search) {
state.jump.results = [];
return;
}
const resources = state.tabs
.map((t) =>
t.Rows.map((r) => ({ ...r, Parent: t.Title, ParentKey: t.Key }))
)
.flat();
let identifiedResources = resources.filter((r) =>
r._representation
.map((f) => f.value)
.join('|')
.toLowerCase()
.includes(search.toLowerCase())
);
state.jump.results = [...identifiedResources];
state.navigation.currentMenuRow = 1;
// pass
},
/**
* Public - Quit the app / Quit the current popup
* Requires prompt
@@ -2359,7 +2517,8 @@
if (
state.isMenuIng &&
state.navigation.currentMenuRow > 0 &&
!state.overview.isEnabled
!state.overview.isEnabled &&
!state.jump.isEnabled
) {
// prettier-ignore
const row = hgetPopupRow(state.popup, state.navigation.currentMenuRow);
@@ -2456,6 +2615,30 @@
return;
}
// Jump confirm
if (
state.isMenuIng &&
state.navigation.currentMenuRow > 0 &&
state.jump.isEnabled
) {
if (state.jump.results.length === 0) return;
// prettier-ignore
const currentResult = { ...state.jump.results[state.navigation.currentMenuRow - 1] };
cmdRun(cmds._clearJump);
state.navigation.currentTab = currentResult.ParentKey;
state.navigation.currentTabsRows[currentResult.ParentKey] =
sgetCurrentTab().Rows.findIndex((r) =>
r.ID ? r.ID === currentResult.ID : r.Name === currentResult.Name
) + 1;
cmdRun(cmds._inspectorTabs);
return;
}
// Search confirm
if (state.search.isEnabled) {
const isForLogs =
@@ -2513,6 +2696,11 @@
return;
}
if (state.jump.isEnabled) {
cmdRun(cmds._clearJump);
return;
}
if (state.popup) {
cmdRun(cmds._clearPopup);
return;
@@ -2691,7 +2879,11 @@
*/
scrollDown: function () {
// Menu - Next row
if (state.isMenuIng && !state.overview.isEnabled) {
if (
state.isMenuIng &&
!state.overview.isEnabled &&
!state.jump.isEnabled
) {
const availableRows = state.menu.actions;
state.navigation.currentMenuRow += 1;
@@ -2707,7 +2899,17 @@
const availableRows = state.overview.Instances;
state.navigation.currentMenuRow += 1;
// +1 is added to account for the extra "cancel" option
if (state.navigation.currentMenuRow > availableRows.length)
state.navigation.currentMenuRow = 1;
return;
}
// Jump - Next row
if (state.isMenuIng && state.jump.isEnabled) {
const availableRows = state.jump.results;
state.navigation.currentMenuRow += 1;
if (state.navigation.currentMenuRow > availableRows.length)
state.navigation.currentMenuRow = 1;
@@ -2746,7 +2948,11 @@
*/
scrollUp: function () {
// Menu - Previous row
if (state.isMenuIng && !state.overview.isEnabled) {
if (
state.isMenuIng &&
!state.overview.isEnabled &&
!state.jump.isEnabled
) {
const availableRows = state.menu.actions;
state.navigation.currentMenuRow -= 1;
@@ -2762,7 +2968,17 @@
const availableRows = state.overview.Instances;
state.navigation.currentMenuRow -= 1;
// +1 is added to account for the extra "cancel" option
if (state.navigation.currentMenuRow < 1)
state.navigation.currentMenuRow = availableRows.length;
return;
}
// Jump - Previous row
if (state.isMenuIng && state.jump.isEnabled) {
const availableRows = state.jump.results;
state.navigation.currentMenuRow -= 1;
if (state.navigation.currentMenuRow < 1)
state.navigation.currentMenuRow = availableRows.length;
@@ -3297,6 +3513,15 @@
websocketSend({ action: `overview`, Agent: agent }, true);
}
},
/**
* Public - Jump to any resource
*/
jump: function () {
state.jump.isEnabled = true;
state.helper = 'jump';
cmdRun(cmds._showPopup, 'jump');
},
};
// === Variables
@@ -3390,6 +3615,7 @@
// Misc
'?': 'help',
'/': 'search',
J: 'jump',
// Appearance
'+': 'nextLayout', // Next layout
@@ -3424,6 +3650,13 @@
return;
}
if (state.jump.isEnabled) {
// Menu navigation
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(evt.key))
evt.preventDefault();
else return;
}
if (evt.metaKey) return;
let { key } = evt;
@@ -3441,10 +3674,15 @@
/**
* Called every time the user releases a key up
* on their keyboard. Will exclusively be handled during
* search, as it requires live input and off-by-one characters
* wouldn't work
* search and jump, as it requires live input and
* off-by-one characters wouldn't work
*/
const listenerKeyUp = (evt) => {
if (state.jump.isEnabled) {
listenerJumpKeyUp(evt);
return;
}
if (!state.search.isEnabled) return;
if (state.search.isPending) return;
@@ -3543,6 +3781,45 @@
cmdRun(cmds._performSearch);
};
/**
* Called every time the user presses a key up
* on their keyboard, while in jump mode. Will
* allow the user to input data using any key, while
* Escape and Enter will be used to Leave/Confirm the
* mode, along with arrows keys for navigation
*/
const listenerJumpKeyUp = (evt) => {
if (evt.metaKey) return;
const { key } = evt;
if (!key) return;
// Cancel / Confirm
if (['Escape', 'Enter'].includes(key)) {
cmdRun(cmds[kbMap[key]]);
return;
}
// Normal input
if (key.length === 1 && /[a-zA-Z0-9-_ ]/.test(key)) {
// No need to update if search hasn't changed
if (state.jump.search === hgetJumpInput().value) return;
state.jump.search = hgetJumpInput().value;
debouncedCmdRun(cmds._performJumpSearch);
}
// Erase
if (['Backspace', 'Delete'].includes(key)) {
state.jump.search = hgetJumpInput().value;
debouncedCmdRun(cmds._performJumpSearch);
return;
}
return;
};
/**
* Called every time the user clicks with their
* mouse. Will call the internal command
@@ -3666,6 +3943,22 @@
cmdRun(cmds.confirm);
}
// 4. Explicit jump via data-jump attribute (e.g. data-jump="images.great-author/wonderful-image")
else if (target.hasAttribute('data-jump')) {
const [tabKey, resourceID] = target.getAttribute('data-jump').split('.');
cmdRun(cmds._clearJump);
state.navigation.currentTab = tabKey;
state.navigation.currentTabsRows[tabKey] =
sgetCurrentTab().Rows.findIndex((r) =>
r.ID ? r.ID === resourceID : r.Name === resourceID
) + 1;
cmdRun(cmds._inspectorTabs);
return;
}
// 4. Clicked anywhere else
else {
// 4.0. If tty-ing, focus the tty input
@@ -3674,12 +3967,18 @@
return;
}
// 4.1. If any popup is activated, while not menuing and not tty-ing, dismiss it
// 4.1. If jump-ing, focus the jump input
if (state.jump.isEnabled) {
hgetJumpInput().focus();
return;
}
// 4.2. If any popup is activated, while not menuing and not tty-ing, dismiss it
if (!state.isMenuIng && state.popup) cmdRun(cmds.reject);
// 4.2. If menuing, and clicked outside the menu, dismiss it
// 4.3. If menuing, and clicked outside the menu, dismiss it
else if (state.isMenuIng && target.classList.contains('popup-layer'))
cmdRun(cmds.reject);
// 4.3. If menuing, and clicked inside the menu, run the 3. scenario (assumption: we clicked a span inside a row)
// 4.4. If menuing, and clicked inside the menu, run the 3. scenario (assumption: we clicked a span inside a row)
else {
// Clicked inside Overview's menu
let tabContent, tabRow, rowIndex;
@@ -4088,5 +4387,7 @@
// 4. Set mouse listener (third execution loop)
window.addEventListener('click', listenerMouseClick);
window.xxx = state;
});
})(window);

View File

@@ -59,7 +59,10 @@
↑ ↓: navigate, enter: pick, esc: close
</p>
<p class="help for-overview">
↑ ↓: navigate, enter: pick
↑ ↓: navigate, enter: pick, esc: close
</p>
<p class="help for-jump">
↑ ↓: navigate, enter: jump, esc: close
</p>
<p class="help for-parameters">
↑ ↓: navigate, enter: toggle, esc: close