Skip to content

Commit 6333d01

Browse files
committed
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.
1 parent d46021a commit 6333d01

File tree

7 files changed

+460
-4
lines changed

7 files changed

+460
-4
lines changed

templates/repo/home_sidebar_top.tmpl

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
<div class="repo-home-sidebar-top">
22
<form class="ignore-dirty tw-flex tw-flex-1" action="{{.RepoLink}}/search" method="get">
3-
<div class="ui small action input tw-flex-1">
4-
<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}"> {{template "shared/search/button"}}
3+
<div class="ui small action input tw-flex-1 repo-code-search-input-wrapper">
4+
<input name="q" size="10" placeholder="{{ctx.Locale.Tr "search.code_kind"}}" class="code-search-input">
5+
<kbd class="repo-search-shortcut-hint">S</kbd>
6+
{{template "shared/search/button"}}
57
</div>
68
</form>
79

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
import {test, expect} from '@playwright/test';
5+
import {login_user, load_logged_in_context} from './utils_e2e.ts';
6+
7+
test.beforeAll(async ({browser}, workerInfo) => {
8+
await login_user(browser, workerInfo, 'user2');
9+
});
10+
11+
test.describe('Repository Keyboard Shortcuts', () => {
12+
test('T key focuses file search input', async ({browser}, workerInfo) => {
13+
const context = await load_logged_in_context(browser, workerInfo, 'user2');
14+
const page = await context.newPage();
15+
16+
// Navigate to a repository page with file listing
17+
await page.goto('/user2/repo1');
18+
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
19+
20+
// Verify the file search input exists and has the keyboard hint
21+
const fileSearchInput = page.locator('.repo-file-search-container input');
22+
await expect(fileSearchInput).toBeVisible();
23+
24+
// Verify the keyboard hint is visible
25+
const kbdHint = page.locator('.repo-file-search-input-wrapper kbd');
26+
await expect(kbdHint).toBeVisible();
27+
await expect(kbdHint).toHaveText('T');
28+
29+
// Press T key to focus the file search input
30+
await page.keyboard.press('t');
31+
32+
// Verify the input is focused
33+
await expect(fileSearchInput).toBeFocused();
34+
});
35+
36+
test('T key does not trigger when typing in input', async ({browser}, workerInfo) => {
37+
const context = await load_logged_in_context(browser, workerInfo, 'user2');
38+
const page = await context.newPage();
39+
40+
// Navigate to a repository page
41+
await page.goto('/user2/repo1');
42+
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
43+
44+
// Focus on file search first
45+
const fileSearchInput = page.locator('.repo-file-search-container input');
46+
await fileSearchInput.click();
47+
48+
// Type something including 't'
49+
await page.keyboard.type('test');
50+
51+
// Verify the input still has focus and contains the typed text
52+
await expect(fileSearchInput).toBeFocused();
53+
await expect(fileSearchInput).toHaveValue('test');
54+
});
55+
56+
test('S key focuses code search input on repo home', async ({browser}, workerInfo) => {
57+
const context = await load_logged_in_context(browser, workerInfo, 'user2');
58+
const page = await context.newPage();
59+
60+
// Navigate to repo home page where code search is available
61+
await page.goto('/user2/repo1');
62+
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
63+
64+
// The code search input is in the sidebar
65+
const codeSearchInput = page.locator('.code-search-input');
66+
await expect(codeSearchInput).toBeVisible();
67+
68+
// Verify the keyboard hint is visible
69+
const kbdHint = page.locator('.repo-code-search-input-wrapper .repo-search-shortcut-hint');
70+
await expect(kbdHint).toBeVisible();
71+
await expect(kbdHint).toHaveText('S');
72+
73+
// Press S key to focus the code search input
74+
await page.keyboard.press('s');
75+
76+
// Verify the input is focused
77+
await expect(codeSearchInput).toBeFocused();
78+
});
79+
80+
test('File search keyboard hint hides when input has value', async ({browser}, workerInfo) => {
81+
const context = await load_logged_in_context(browser, workerInfo, 'user2');
82+
const page = await context.newPage();
83+
84+
// Navigate to a repository page
85+
await page.goto('/user2/repo1');
86+
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
87+
88+
// Check file search kbd hint
89+
const fileSearchInput = page.locator('.repo-file-search-container input');
90+
const fileKbdHint = page.locator('.repo-file-search-input-wrapper kbd');
91+
92+
// Initially the hint should be visible
93+
await expect(fileKbdHint).toBeVisible();
94+
95+
// Focus and type in the file search
96+
await fileSearchInput.click();
97+
await page.keyboard.type('test');
98+
99+
// The hint should now be hidden (Vue component handles this with v-show)
100+
await expect(fileKbdHint).toBeHidden();
101+
});
102+
103+
test('Code search keyboard hint hides when input has value', async ({browser}, workerInfo) => {
104+
const context = await load_logged_in_context(browser, workerInfo, 'user2');
105+
const page = await context.newPage();
106+
107+
// Navigate to a repository page
108+
await page.goto('/user2/repo1');
109+
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
110+
111+
const codeSearchInput = page.locator('.code-search-input');
112+
await expect(codeSearchInput).toBeVisible();
113+
114+
const codeKbdHint = page.locator('.repo-code-search-input-wrapper .repo-search-shortcut-hint');
115+
116+
// Initially the hint should be visible
117+
await expect(codeKbdHint).toBeVisible();
118+
119+
// Focus and type in the code search
120+
await codeSearchInput.click();
121+
await page.keyboard.type('search');
122+
123+
// The hint should now be hidden
124+
await expect(codeKbdHint).toBeHidden();
125+
});
126+
127+
test('Shortcuts do not trigger with modifier keys', async ({browser}, workerInfo) => {
128+
const context = await load_logged_in_context(browser, workerInfo, 'user2');
129+
const page = await context.newPage();
130+
131+
// Navigate to a repository page
132+
await page.goto('/user2/repo1');
133+
await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
134+
135+
const fileSearchInput = page.locator('.repo-file-search-container input');
136+
137+
// Click somewhere else first to ensure nothing is focused
138+
await page.locator('body').click();
139+
140+
// Press Ctrl+T (should not focus file search - this is typically "new tab" in browsers)
141+
await page.keyboard.press('Control+t');
142+
143+
// The file search input should NOT be focused
144+
await expect(fileSearchInput).not.toBeFocused();
145+
});
146+
});

web_src/css/repo.css

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,3 +2066,42 @@ tbody.commit-list {
20662066
.branch-selector-dropdown .scrolling.menu .loading-indicator {
20672067
height: 4em;
20682068
}
2069+
2070+
/* Keyboard shortcut hint styles for repo search inputs */
2071+
.repo-code-search-input-wrapper {
2072+
position: relative;
2073+
}
2074+
2075+
.repo-code-search-input-wrapper input {
2076+
padding-right: 32px !important;
2077+
}
2078+
2079+
.repo-search-shortcut-hint {
2080+
position: absolute;
2081+
right: 40px; /* account for the search button */
2082+
top: 50%;
2083+
transform: translateY(-50%);
2084+
display: inline-block;
2085+
padding: 2px 6px;
2086+
font-size: 11px;
2087+
line-height: 14px;
2088+
color: var(--color-text-light-2);
2089+
background-color: var(--color-box-body);
2090+
border: 1px solid var(--color-secondary);
2091+
border-radius: var(--border-radius);
2092+
box-shadow: inset 0 -1px 0 var(--color-secondary);
2093+
pointer-events: none;
2094+
z-index: 1;
2095+
}
2096+
2097+
/* Override Fomantic UI action input styles for file search - need high specificity */
2098+
.repo-file-search-input-wrapper.ui.input input,
2099+
.repo-file-search-input-wrapper.ui.input input:hover {
2100+
border-right: 1px solid var(--color-input-border) !important;
2101+
border-top-right-radius: 0.28571429rem !important;
2102+
border-bottom-right-radius: 0.28571429rem !important;
2103+
}
2104+
2105+
.repo-file-search-input-wrapper.ui.input input:focus {
2106+
border-color: var(--color-primary) !important;
2107+
}

web_src/js/components/RepoFileSearch.vue

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const allFiles = ref<string[]>([]);
2323
const selectedIndex = ref(0);
2424
const isLoadingFileList = ref(false);
2525
const hasLoadedFileList = ref(false);
26+
const isInputFocused = ref(false);
2627
2728
const showPopup = computed(() => searchQuery.value.length > 0);
2829
@@ -43,8 +44,8 @@ const handleSearchInput = () => {
4344
4445
const handleKeyDown = (e: KeyboardEvent) => {
4546
if (e.key === 'Escape') {
46-
e.preventDefault();
4747
clearSearch();
48+
nextTick(() => refElemInput.value.blur());
4849
return;
4950
}
5051
if (!searchQuery.value || filteredFiles.value.length === 0) return;
@@ -143,12 +144,14 @@ watch([searchQuery, filteredFiles], async () => {
143144

144145
<template>
145146
<div>
146-
<div class="ui small input">
147+
<div class="ui small input repo-file-search-input-wrapper">
147148
<input
148149
ref="searchInput" :placeholder="placeholder" autocomplete="off"
149150
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
150151
@input="handleSearchInput" @keydown="handleKeyDown"
152+
@focus="isInputFocused = true" @blur="isInputFocused = false"
151153
>
154+
<kbd v-show="!searchQuery && !isInputFocused" class="repo-search-shortcut-hint">T</kbd>
152155
</div>
153156

154157
<Teleport to="body">
@@ -181,6 +184,42 @@ watch([searchQuery, filteredFiles], async () => {
181184
</template>
182185

183186
<style scoped>
187+
.repo-file-search-input-wrapper {
188+
position: relative;
189+
}
190+
191+
.repo-file-search-input-wrapper input {
192+
padding-right: 32px !important;
193+
border-right: 1px solid var(--color-input-border) !important;
194+
border-top-right-radius: 0.28571429rem !important;
195+
border-bottom-right-radius: 0.28571429rem !important;
196+
}
197+
198+
.repo-file-search-input-wrapper input:focus {
199+
border-color: var(--color-primary) !important;
200+
}
201+
202+
.repo-search-shortcut-hint {
203+
position: absolute;
204+
right: 10px;
205+
top: 50%;
206+
transform: translateY(-50%);
207+
display: inline-block;
208+
padding: 2px 5px;
209+
font-size: 11px;
210+
line-height: 12px;
211+
color: var(--color-text-light-2);
212+
background-color: var(--color-box-body);
213+
border: 1px solid var(--color-secondary);
214+
border-radius: 3px;
215+
pointer-events: none;
216+
}
217+
218+
/* Hide kbd when input is focused so it doesn't interfere with focus border */
219+
.repo-file-search-input-wrapper input:focus + .repo-search-shortcut-hint {
220+
display: none;
221+
}
222+
184223
.file-search-popup {
185224
position: absolute;
186225
background: var(--color-box-body);

0 commit comments

Comments
 (0)