Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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">
<kbd class="repo-search-shortcut-hint">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"
@input="handleSearchInput" @keydown="handleKeyDown"
@focus="isInputFocused = true" @blur="isInputFocused = false"
>
<kbd v-show="!searchQuery && !isInputFocused" class="repo-file-search-shortcut-hint">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
Loading