mirror of
https://github.com/go-gitea/gitea.git
synced 2026-02-22 13:15:23 +01:00
feat(shortcut): Add keyboard shortcuts for repository file and code search
Add GitHub-like keyboard shortcuts for repository navigation: - Press 'T' to focus the "Go to file" search input - Press 'S' to focus the "Search code" input - Press 'Escape' to blur focused search inputs Both search inputs display a keyboard hint (kbd element) showing the available shortcut. The hint hides when the input is focused or has a value. Includes unit tests and e2e tests for the new functionality.
This commit is contained in:
parent
a6282c98d7
commit
7226ecde9a
@ -1,7 +1,9 @@
|
||||
<div class="repo-home-sidebar-top">
|
||||
<form class="ignore-dirty tw-flex tw-flex-1" action="{{.RepoLink}}/search" method="get">
|
||||
<div class="ui small action input tw-flex-1">
|
||||
<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}"> {{template "shared/search/button"}}
|
||||
<div class="ui small action input tw-flex-1 repo-code-search-input-wrapper">
|
||||
<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}" class="code-search-input">
|
||||
<kbd class="repo-search-shortcut-hint">S</kbd>
|
||||
{{template "shared/search/button"}}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
146
tests/e2e/repo-shortcuts.test.e2e.ts
Normal file
146
tests/e2e/repo-shortcuts.test.e2e.ts
Normal file
@ -0,0 +1,146 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login_user, load_logged_in_context} from './utils_e2e.ts';
|
||||
|
||||
test.beforeAll(async ({browser}, workerInfo) => {
|
||||
await login_user(browser, workerInfo, 'user2');
|
||||
});
|
||||
|
||||
test.describe('Repository Keyboard Shortcuts', () => {
|
||||
test('T key focuses file search input', async ({browser}, workerInfo) => {
|
||||
const context = await load_logged_in_context(browser, workerInfo, 'user2');
|
||||
const page = await context.newPage();
|
||||
|
||||
// Navigate to a repository page with file listing
|
||||
await page.goto('/user2/repo1');
|
||||
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
|
||||
|
||||
// Verify the file search input exists and has the keyboard hint
|
||||
const fileSearchInput = page.locator('.repo-file-search-container input');
|
||||
await expect(fileSearchInput).toBeVisible();
|
||||
|
||||
// Verify the keyboard hint is visible
|
||||
const kbdHint = page.locator('.repo-file-search-input-wrapper kbd');
|
||||
await expect(kbdHint).toBeVisible();
|
||||
await expect(kbdHint).toHaveText('T');
|
||||
|
||||
// Press T key to focus the file search input
|
||||
await page.keyboard.press('t');
|
||||
|
||||
// Verify the input is focused
|
||||
await expect(fileSearchInput).toBeFocused();
|
||||
});
|
||||
|
||||
test('T key does not trigger when typing in input', async ({browser}, workerInfo) => {
|
||||
const context = await load_logged_in_context(browser, workerInfo, 'user2');
|
||||
const page = await context.newPage();
|
||||
|
||||
// Navigate to a repository page
|
||||
await page.goto('/user2/repo1');
|
||||
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
|
||||
|
||||
// Focus on file search first
|
||||
const fileSearchInput = page.locator('.repo-file-search-container input');
|
||||
await fileSearchInput.click();
|
||||
|
||||
// Type something including 't'
|
||||
await page.keyboard.type('test');
|
||||
|
||||
// Verify the input still has focus and contains the typed text
|
||||
await expect(fileSearchInput).toBeFocused();
|
||||
await expect(fileSearchInput).toHaveValue('test');
|
||||
});
|
||||
|
||||
test('S key focuses code search input on repo home', async ({browser}, workerInfo) => {
|
||||
const context = await load_logged_in_context(browser, workerInfo, 'user2');
|
||||
const page = await context.newPage();
|
||||
|
||||
// Navigate to repo home page where code search is available
|
||||
await page.goto('/user2/repo1');
|
||||
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
|
||||
|
||||
// The code search input is in the sidebar
|
||||
const codeSearchInput = page.locator('.code-search-input');
|
||||
await expect(codeSearchInput).toBeVisible();
|
||||
|
||||
// Verify the keyboard hint is visible
|
||||
const kbdHint = page.locator('.repo-code-search-input-wrapper .repo-search-shortcut-hint');
|
||||
await expect(kbdHint).toBeVisible();
|
||||
await expect(kbdHint).toHaveText('S');
|
||||
|
||||
// Press S key to focus the code search input
|
||||
await page.keyboard.press('s');
|
||||
|
||||
// Verify the input is focused
|
||||
await expect(codeSearchInput).toBeFocused();
|
||||
});
|
||||
|
||||
test('File search keyboard hint hides when input has value', async ({browser}, workerInfo) => {
|
||||
const context = await load_logged_in_context(browser, workerInfo, 'user2');
|
||||
const page = await context.newPage();
|
||||
|
||||
// Navigate to a repository page
|
||||
await page.goto('/user2/repo1');
|
||||
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
|
||||
|
||||
// Check file search kbd hint
|
||||
const fileSearchInput = page.locator('.repo-file-search-container input');
|
||||
const fileKbdHint = page.locator('.repo-file-search-input-wrapper kbd');
|
||||
|
||||
// Initially the hint should be visible
|
||||
await expect(fileKbdHint).toBeVisible();
|
||||
|
||||
// Focus and type in the file search
|
||||
await fileSearchInput.click();
|
||||
await page.keyboard.type('test');
|
||||
|
||||
// The hint should now be hidden (Vue component handles this with v-show)
|
||||
await expect(fileKbdHint).toBeHidden();
|
||||
});
|
||||
|
||||
test('Code search keyboard hint hides when input has value', async ({browser}, workerInfo) => {
|
||||
const context = await load_logged_in_context(browser, workerInfo, 'user2');
|
||||
const page = await context.newPage();
|
||||
|
||||
// Navigate to a repository page
|
||||
await page.goto('/user2/repo1');
|
||||
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
|
||||
|
||||
const codeSearchInput = page.locator('.code-search-input');
|
||||
await expect(codeSearchInput).toBeVisible();
|
||||
|
||||
const codeKbdHint = page.locator('.repo-code-search-input-wrapper .repo-search-shortcut-hint');
|
||||
|
||||
// Initially the hint should be visible
|
||||
await expect(codeKbdHint).toBeVisible();
|
||||
|
||||
// Focus and type in the code search
|
||||
await codeSearchInput.click();
|
||||
await page.keyboard.type('search');
|
||||
|
||||
// The hint should now be hidden
|
||||
await expect(codeKbdHint).toBeHidden();
|
||||
});
|
||||
|
||||
test('Shortcuts do not trigger with modifier keys', async ({browser}, workerInfo) => {
|
||||
const context = await load_logged_in_context(browser, workerInfo, 'user2');
|
||||
const page = await context.newPage();
|
||||
|
||||
// Navigate to a repository page
|
||||
await page.goto('/user2/repo1');
|
||||
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
|
||||
|
||||
const fileSearchInput = page.locator('.repo-file-search-container input');
|
||||
|
||||
// Click somewhere else first to ensure nothing is focused
|
||||
await page.locator('body').click();
|
||||
|
||||
// Press Ctrl+T (should not focus file search - this is typically "new tab" in browsers)
|
||||
await page.keyboard.press('Control+t');
|
||||
|
||||
// The file search input should NOT be focused
|
||||
await expect(fileSearchInput).not.toBeFocused();
|
||||
});
|
||||
});
|
||||
@ -2064,3 +2064,42 @@ tbody.commit-list {
|
||||
.branch-selector-dropdown .scrolling.menu .loading-indicator {
|
||||
height: 4em;
|
||||
}
|
||||
|
||||
/* Keyboard shortcut hint styles for repo search inputs */
|
||||
.repo-code-search-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.repo-code-search-input-wrapper input {
|
||||
padding-right: 32px !important;
|
||||
}
|
||||
|
||||
.repo-search-shortcut-hint {
|
||||
position: absolute;
|
||||
right: 40px; /* account for the search button */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
color: var(--color-text-light-2);
|
||||
background-color: var(--color-box-body);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: inset 0 -1px 0 var(--color-secondary);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Override Fomantic UI action input styles for file search - need high specificity */
|
||||
.repo-file-search-input-wrapper.ui.input input,
|
||||
.repo-file-search-input-wrapper.ui.input input:hover {
|
||||
border-right: 1px solid var(--color-input-border) !important;
|
||||
border-top-right-radius: 0.28571429rem !important;
|
||||
border-bottom-right-radius: 0.28571429rem !important;
|
||||
}
|
||||
|
||||
.repo-file-search-input-wrapper.ui.input input:focus {
|
||||
border-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ const allFiles = ref<string[]>([]);
|
||||
const selectedIndex = ref(0);
|
||||
const isLoadingFileList = ref(false);
|
||||
const hasLoadedFileList = ref(false);
|
||||
const isInputFocused = ref(false);
|
||||
|
||||
const showPopup = computed(() => searchQuery.value.length > 0);
|
||||
|
||||
@ -45,8 +46,8 @@ const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
clearSearch();
|
||||
nextTick(() => refElemInput.value.blur());
|
||||
return;
|
||||
}
|
||||
if (!searchQuery.value || filteredFiles.value.length === 0) return;
|
||||
@ -145,12 +146,14 @@ watch([searchQuery, filteredFiles], async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="ui small input">
|
||||
<div class="ui small input repo-file-search-input-wrapper">
|
||||
<input
|
||||
ref="searchInput" :placeholder="placeholder" autocomplete="off"
|
||||
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
|
||||
@input="handleSearchInput" @keydown="handleKeyDown"
|
||||
@focus="isInputFocused = true" @blur="isInputFocused = false"
|
||||
>
|
||||
<kbd v-show="!searchQuery && !isInputFocused" class="repo-search-shortcut-hint">T</kbd>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
@ -183,6 +186,42 @@ watch([searchQuery, filteredFiles], async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.repo-file-search-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.repo-file-search-input-wrapper input {
|
||||
padding-right: 32px !important;
|
||||
border-right: 1px solid var(--color-input-border) !important;
|
||||
border-top-right-radius: 0.28571429rem !important;
|
||||
border-bottom-right-radius: 0.28571429rem !important;
|
||||
}
|
||||
|
||||
.repo-file-search-input-wrapper input:focus {
|
||||
border-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.repo-search-shortcut-hint {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-block;
|
||||
padding: 2px 5px;
|
||||
font-size: 11px;
|
||||
line-height: 12px;
|
||||
color: var(--color-text-light-2);
|
||||
background-color: var(--color-box-body);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hide kbd when input is focused so it doesn't interfere with focus border */
|
||||
.repo-file-search-input-wrapper input:focus + .repo-search-shortcut-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-search-popup {
|
||||
position: absolute;
|
||||
background: var(--color-box-body);
|
||||
|
||||
152
web_src/js/features/repo-shortcuts.test.ts
Normal file
152
web_src/js/features/repo-shortcuts.test.ts
Normal file
@ -0,0 +1,152 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import {initRepoShortcuts} from './repo-shortcuts.ts';
|
||||
|
||||
describe('Repository Keyboard Shortcuts', () => {
|
||||
let fileSearchInput: HTMLInputElement;
|
||||
let codeSearchInput: HTMLInputElement;
|
||||
let codeSearchHint: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// Set up DOM structure
|
||||
document.body.innerHTML = `
|
||||
<div class="repo-file-search-container">
|
||||
<div class="repo-file-search-input-wrapper">
|
||||
<input type="text" placeholder="Go to file">
|
||||
<kbd class="repo-search-shortcut-hint">T</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-home-sidebar-top">
|
||||
<div class="repo-code-search-input-wrapper">
|
||||
<input name="q" class="code-search-input" placeholder="Search code">
|
||||
<kbd class="repo-search-shortcut-hint">S</kbd>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
fileSearchInput = document.querySelector('.repo-file-search-container input')!;
|
||||
codeSearchInput = document.querySelector('.code-search-input')!;
|
||||
codeSearchHint = document.querySelector('.repo-code-search-input-wrapper .repo-search-shortcut-hint')!;
|
||||
|
||||
initRepoShortcuts();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('T key focuses file search input', () => {
|
||||
const event = new KeyboardEvent('keydown', {key: 't', bubbles: true});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(document.activeElement).toBe(fileSearchInput);
|
||||
});
|
||||
|
||||
test('Shift+T (uppercase T) focuses file search input', () => {
|
||||
const event = new KeyboardEvent('keydown', {key: 'T', bubbles: true});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(document.activeElement).toBe(fileSearchInput);
|
||||
});
|
||||
|
||||
test('S key focuses code search input', () => {
|
||||
const event = new KeyboardEvent('keydown', {key: 's', bubbles: true});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(document.activeElement).toBe(codeSearchInput);
|
||||
});
|
||||
|
||||
test('Shortcuts do not trigger when typing in input', () => {
|
||||
// Focus on an input field first
|
||||
const otherInput = document.createElement('input');
|
||||
document.body.append(otherInput);
|
||||
otherInput.focus();
|
||||
|
||||
const event = new KeyboardEvent('keydown', {key: 't', bubbles: true});
|
||||
Object.defineProperty(event, 'target', {value: otherInput});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// File search should not be focused because we're already in an input
|
||||
expect(document.activeElement).toBe(otherInput);
|
||||
});
|
||||
|
||||
test('Shortcuts do not trigger with Ctrl modifier', () => {
|
||||
const event = new KeyboardEvent('keydown', {key: 't', ctrlKey: true, bubbles: true});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(document.activeElement).not.toBe(fileSearchInput);
|
||||
});
|
||||
|
||||
test('Shortcuts do not trigger with Meta modifier', () => {
|
||||
const event = new KeyboardEvent('keydown', {key: 's', metaKey: true, bubbles: true});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(document.activeElement).not.toBe(codeSearchInput);
|
||||
});
|
||||
|
||||
test('Shortcuts do not trigger with Alt modifier', () => {
|
||||
const event = new KeyboardEvent('keydown', {key: 't', altKey: true, bubbles: true});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(document.activeElement).not.toBe(fileSearchInput);
|
||||
});
|
||||
|
||||
test('Code search hint hides when input has value', () => {
|
||||
// Initially visible
|
||||
expect(codeSearchHint.style.display).toBe('');
|
||||
|
||||
// Type something in the code search
|
||||
codeSearchInput.value = 'test';
|
||||
codeSearchInput.dispatchEvent(new Event('input'));
|
||||
|
||||
// Should be hidden
|
||||
expect(codeSearchHint.style.display).toBe('none');
|
||||
});
|
||||
|
||||
test('Code search hint shows when input is cleared', () => {
|
||||
// Set a value and trigger input
|
||||
codeSearchInput.value = 'test';
|
||||
codeSearchInput.dispatchEvent(new Event('input'));
|
||||
expect(codeSearchHint.style.display).toBe('none');
|
||||
|
||||
// Clear the value
|
||||
codeSearchInput.value = '';
|
||||
codeSearchInput.dispatchEvent(new Event('input'));
|
||||
|
||||
// Should be visible again
|
||||
expect(codeSearchHint.style.display).toBe('');
|
||||
});
|
||||
|
||||
test('Escape key blurs code search input', () => {
|
||||
// Focus the code search input first
|
||||
codeSearchInput.focus();
|
||||
expect(document.activeElement).toBe(codeSearchInput);
|
||||
|
||||
// Press Escape directly on the input (the input has its own keydown handler)
|
||||
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
|
||||
codeSearchInput.dispatchEvent(event);
|
||||
|
||||
// Should no longer be focused
|
||||
expect(document.activeElement).not.toBe(codeSearchInput);
|
||||
});
|
||||
|
||||
test('Code search kbd hint hides on focus', () => {
|
||||
// Initially visible
|
||||
expect(codeSearchHint.style.display).toBe('');
|
||||
|
||||
// Focus the input
|
||||
codeSearchInput.focus();
|
||||
codeSearchInput.dispatchEvent(new Event('focus'));
|
||||
|
||||
// Should be hidden
|
||||
expect(codeSearchHint.style.display).toBe('none');
|
||||
|
||||
// Blur the input
|
||||
codeSearchInput.blur();
|
||||
codeSearchInput.dispatchEvent(new Event('blur'));
|
||||
|
||||
// Should be visible again
|
||||
expect(codeSearchHint.style.display).toBe('');
|
||||
});
|
||||
});
|
||||
76
web_src/js/features/repo-shortcuts.ts
Normal file
76
web_src/js/features/repo-shortcuts.ts
Normal file
@ -0,0 +1,76 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
/**
|
||||
* Initialize global keyboard shortcuts for repository pages.
|
||||
* - 'T' key: Focus the "Go to file" search input
|
||||
* - 'S' key: Focus the "Search code" input
|
||||
*
|
||||
* Shortcuts are disabled when the user is typing in an input field,
|
||||
* textarea, or contenteditable element.
|
||||
*/
|
||||
export function initRepoShortcuts(): void {
|
||||
// Initialize keyboard shortcut listeners
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in input fields
|
||||
const target = e.target as HTMLElement;
|
||||
if (target instanceof HTMLElement && target.matches('input, textarea, select, [contenteditable="true"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't trigger shortcuts when modifier keys are pressed
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 't' || e.key === 'T') {
|
||||
const fileSearchInput = document.querySelector<HTMLInputElement>('.repo-file-search-container input');
|
||||
if (fileSearchInput) {
|
||||
e.preventDefault();
|
||||
fileSearchInput.focus();
|
||||
}
|
||||
} else if (e.key === 's' || e.key === 'S') {
|
||||
const codeSearchInput = document.querySelector<HTMLInputElement>('.repo-home-sidebar-top input[name="q"], .code-search-input');
|
||||
if (codeSearchInput) {
|
||||
e.preventDefault();
|
||||
codeSearchInput.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle shortcut hint visibility for code search input based on input value and focus state
|
||||
const codeSearchInput = document.querySelector<HTMLInputElement>('.code-search-input');
|
||||
if (codeSearchInput) {
|
||||
const shortcutHint = codeSearchInput.parentElement?.querySelector<HTMLElement>('.repo-search-shortcut-hint');
|
||||
if (shortcutHint) {
|
||||
let isFocused = false;
|
||||
|
||||
const updateHintVisibility = () => {
|
||||
shortcutHint.style.display = (codeSearchInput.value || isFocused) ? 'none' : '';
|
||||
};
|
||||
|
||||
// Check initial value (e.g., from browser autofill or back navigation)
|
||||
updateHintVisibility();
|
||||
|
||||
codeSearchInput.addEventListener('input', updateHintVisibility);
|
||||
codeSearchInput.addEventListener('change', updateHintVisibility);
|
||||
codeSearchInput.addEventListener('focus', () => {
|
||||
isFocused = true;
|
||||
updateHintVisibility();
|
||||
});
|
||||
codeSearchInput.addEventListener('blur', () => {
|
||||
isFocused = false;
|
||||
updateHintVisibility();
|
||||
});
|
||||
|
||||
// Handle Escape key to clear and blur the code search input
|
||||
codeSearchInput.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
codeSearchInput.value = '';
|
||||
updateHintVisibility();
|
||||
codeSearchInput.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -55,6 +55,7 @@ import {initRepoRecentCommits} from './features/recent-commits.ts';
|
||||
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
|
||||
import {initGlobalSelectorObserver} from './modules/observer.ts';
|
||||
import {initRepositorySearch} from './features/repo-search.ts';
|
||||
import {initRepoShortcuts} from './features/repo-shortcuts.ts';
|
||||
import {initColorPickers} from './features/colorpicker.ts';
|
||||
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
|
||||
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
|
||||
@ -140,6 +141,7 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initRepository,
|
||||
initRepositoryActionView,
|
||||
initRepositorySearch,
|
||||
initRepoShortcuts,
|
||||
initRepoContributors,
|
||||
initRepoCodeFrequency,
|
||||
initRepoRecentCommits,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user