Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions templates/repo/home_sidebar_top.tmpl
Original file line number Diff line number Diff line change
@@ -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" data-global-keyboard-shortcut="s" data-global-init="initRepoCodeSearchShortcut" aria-keyshortcuts="s">
<kbd class="repo-search-shortcut-hint" aria-hidden="true">S</kbd>
{{template "shared/search/button"}}
</div>
</form>

Expand Down
146 changes: 146 additions & 0 deletions tests/e2e/repo-shortcuts.test.e2e.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
39 changes: 39 additions & 0 deletions web_src/css/repo.css
Original file line number Diff line number Diff line change
Expand Up @@ -2068,3 +2068,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;
}
44 changes: 42 additions & 2 deletions web_src/js/components/RepoFileSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -43,8 +44,8 @@ const handleSearchInput = () => {

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
clearSearch();
nextTick(() => refElemInput.value.blur());
return;
}
if (!searchQuery.value || filteredFiles.value.length === 0) return;
Expand Down Expand Up @@ -143,12 +144,15 @@ 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'"
data-global-keyboard-shortcut="t" aria-keyshortcuts="t"
@input="handleSearchInput" @keydown="handleKeyDown"
@focus="isInputFocused = true" @blur="isInputFocused = false"
>
<kbd v-show="!searchQuery && !isInputFocused" class="repo-file-search-shortcut-hint" aria-hidden="true">T</kbd>
</div>

<Teleport to="body">
Expand Down Expand Up @@ -181,6 +185,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-file-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-file-search-shortcut-hint {
display: none;
}

.file-search-popup {
position: absolute;
background: var(--color-box-body);
Expand Down
105 changes: 105 additions & 0 deletions web_src/js/features/repo-shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

import {initRepoCodeSearchShortcut} from './repo-shortcuts.ts';

describe('Repository Code Search Shortcut Hint', () => {
let codeSearchInput: HTMLInputElement;
let codeSearchHint: HTMLElement;

beforeEach(() => {
// Set up DOM structure for code search
document.body.innerHTML = `
<div class="repo-home-sidebar-top">
<div class="repo-code-search-input-wrapper">
<input name="q" class="code-search-input" placeholder="Search code" data-global-keyboard-shortcut="s" data-global-init="initRepoCodeSearchShortcut">
<kbd class="repo-search-shortcut-hint">S</kbd>
</div>
</div>
`;

codeSearchInput = document.querySelector('.code-search-input')!;
codeSearchHint = document.querySelector('.repo-code-search-input-wrapper .repo-search-shortcut-hint')!;

// Initialize the shortcut hint functionality directly
initRepoCodeSearchShortcut(codeSearchInput);
});

afterEach(() => {
document.body.innerHTML = '';
});

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 clears and blurs code search input', () => {
// Set a value and focus the input
codeSearchInput.value = 'test';
codeSearchInput.dispatchEvent(new Event('input'));
codeSearchInput.focus();
expect(document.activeElement).toBe(codeSearchInput);
expect(codeSearchInput.value).toBe('test');

// Press Escape directly on the input
const event = new KeyboardEvent('keydown', {key: 'Escape', bubbles: true});
codeSearchInput.dispatchEvent(event);

// Value should be cleared and input should be blurred
expect(codeSearchInput.value).toBe('');
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('');
});

test('Change event also updates hint visibility', () => {
// Initially visible
expect(codeSearchHint.style.display).toBe('');

// Set value via change event (e.g., browser autofill)
codeSearchInput.value = 'autofilled';
codeSearchInput.dispatchEvent(new Event('change'));

// Should be hidden
expect(codeSearchHint.style.display).toBe('none');
});
});
Loading