Skip to content

Commit 1cca7b1

Browse files
committed
Improve keyboard navigation and global search hotkey UX
1 parent bdb62a1 commit 1cca7b1

11 files changed

Lines changed: 490 additions & 39 deletions

File tree

anycode-backend/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "anycode"
3-
version = "0.0.13"
3+
version = "0.0.14"
44
edition = "2024"
55

66
[profile.release]

anycode-base/src/editor.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,15 @@ export class AnycodeEditor {
240240
return this.code.getContent();
241241
}
242242

243+
public getSelectedText(): string {
244+
if (!this.selection || this.selection.isEmpty()) {
245+
return '';
246+
}
247+
248+
const [start, end] = this.selection.sorted();
249+
return this.code.getIntervalContent2(start, end);
250+
}
251+
243252
public getTextLength(): number {
244253
return this.code.getContentLength();
245254
}

anycode/App.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,18 @@ const App: React.FC = () => {
269269
e.preventDefault();
270270
}
271271

272+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'f') {
273+
e.preventDefault();
274+
const selectedText = editors.getActiveEditorSelectedText().trim();
275+
layoutActionsRef.current?.ensurePanel('search');
276+
if (selectedText) {
277+
search.setSearchInput(selectedText);
278+
search.startSearch(selectedText);
279+
}
280+
layout.requestPanelFocus('search');
281+
return;
282+
}
283+
272284
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
273285
e.preventDefault();
274286
if (editors.activeFileId) {
@@ -297,10 +309,12 @@ const App: React.FC = () => {
297309
editors.activeEditorPaneId,
298310
editors.activeFileId,
299311
editors.handleReferencesPeekKeyDown,
312+
editors.getActiveEditorSelectedText,
300313
editors.redoCursor,
301314
editors.saveFile,
302315
editors.undoCursor,
303316
layout,
317+
search,
304318
]);
305319

306320
const handleStartSpecificAgent = useCallback((agent: AcpAgent) => {
@@ -349,6 +363,9 @@ const App: React.FC = () => {
349363
return (
350364
<Search
351365
id="search"
366+
focusRequestToken={layout.getFocusRequestToken('search')}
367+
inputValue={search.searchInput}
368+
onInputValueChange={search.setSearchInput}
352369
onEnter={handleSearch}
353370
onInputChange={search.cancelSearch}
354371
onCancel={search.cancelSearch}

anycode/components/ChangesPanel.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,12 @@
244244
padding: 4px;
245245
}
246246

247+
.changes-list:focus,
248+
.changes-list:focus-visible {
249+
outline: none;
250+
box-shadow: none;
251+
}
252+
247253
.changes-header,
248254
.changes-list-header,
249255
.changes-item,
@@ -274,6 +280,18 @@
274280
border-radius: 6px;
275281
}
276282

283+
.changes-item.active {
284+
background-color: #3c3c3c;
285+
border-radius: 6px;
286+
color: #fff;
287+
transition: all 0.1s ease;
288+
}
289+
290+
.changes-list:focus .changes-item.active,
291+
.changes-list:focus-visible .changes-item.active {
292+
box-shadow: inset 0 0 0 1px #7a7a7a;
293+
}
294+
277295
.changes-item.excluded .changes-filename {
278296
opacity: 0.65;
279297
text-decoration: line-through;

anycode/components/ChangesPanel.tsx

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect } from 'react';
1+
import React, { useState, useEffect, useRef, useCallback } from 'react';
22
import { Icons } from './Icons';
33
import './ChangesPanel.css';
44

@@ -57,6 +57,18 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
5757
return localStorage.getItem(COMMIT_MESSAGE_STORAGE_KEY) ?? '';
5858
});
5959
const [excludedFiles, setExcludedFiles] = useState<Set<string>>(new Set());
60+
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
61+
const listRef = useRef<HTMLDivElement | null>(null);
62+
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
63+
const shouldAutoScrollRef = useRef(false);
64+
65+
const setItemRef = useCallback((path: string, element: HTMLDivElement | null) => {
66+
if (element) {
67+
itemRefs.current.set(path, element);
68+
return;
69+
}
70+
itemRefs.current.delete(path);
71+
}, []);
6072

6173
useEffect(() => {
6274
if (typeof window === 'undefined') return;
@@ -83,6 +95,27 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
8395
});
8496
}, [files]);
8597

98+
useEffect(() => {
99+
if (files.length === 0) {
100+
setActiveFilePath(null);
101+
return;
102+
}
103+
104+
if (!activeFilePath || !files.some((file) => file.path === activeFilePath)) {
105+
setActiveFilePath(files[0].path);
106+
}
107+
}, [files, activeFilePath]);
108+
109+
useEffect(() => {
110+
if (!activeFilePath || !shouldAutoScrollRef.current) {
111+
return;
112+
}
113+
114+
const item = itemRefs.current.get(activeFilePath);
115+
item?.scrollIntoView({ block: 'nearest' });
116+
shouldAutoScrollRef.current = false;
117+
}, [activeFilePath]);
118+
86119
const toggleExcludedFile = (path: string, e: React.MouseEvent) => {
87120
e.stopPropagation();
88121
setExcludedFiles(prev => {
@@ -96,6 +129,55 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
96129
});
97130
};
98131

132+
const navigateByKey = useCallback((key: string): boolean => {
133+
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(key)) {
134+
return false;
135+
}
136+
137+
if (files.length === 0) {
138+
return true;
139+
}
140+
141+
const currentIndex = Math.max(0, files.findIndex((file) => file.path === activeFilePath));
142+
143+
if (key === 'ArrowDown') {
144+
const nextIndex = Math.min(files.length - 1, currentIndex + 1);
145+
setActiveFilePath(files[nextIndex].path);
146+
shouldAutoScrollRef.current = true;
147+
return true;
148+
}
149+
150+
if (key === 'ArrowUp') {
151+
const prevIndex = Math.max(0, currentIndex - 1);
152+
setActiveFilePath(files[prevIndex].path);
153+
shouldAutoScrollRef.current = true;
154+
return true;
155+
}
156+
157+
if (key === 'Enter' && activeFilePath) {
158+
onFileClick(activeFilePath);
159+
listRef.current?.blur();
160+
return true;
161+
}
162+
163+
return false;
164+
}, [activeFilePath, files, onFileClick]);
165+
166+
const handleListKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
167+
const handled = navigateByKey(event.key);
168+
if (handled) {
169+
event.preventDefault();
170+
event.stopPropagation();
171+
}
172+
}, [navigateByKey]);
173+
174+
const handleListMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
175+
if (event.button !== 0) {
176+
return;
177+
}
178+
listRef.current?.focus();
179+
}, []);
180+
99181
const filesToCommit = files
100182
.map((file) => file.path)
101183
.filter((path) => !excludedFiles.has(path));
@@ -263,17 +345,31 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
263345
</div>
264346
</div>
265347

266-
<div className="changes-list">
348+
<div
349+
ref={listRef}
350+
className="changes-list"
351+
role="listbox"
352+
tabIndex={0}
353+
aria-label="Changed files"
354+
onKeyDown={handleListKeyDown}
355+
onMouseDown={handleListMouseDown}
356+
>
267357
{files.length === 0 ? (
268358
<div className="changes-empty">
269359
No changes
270360
</div>
271361
) : (
272362
files.map((file) => (
273363
<div
364+
ref={(element) => setItemRef(file.path, element)}
274365
key={file.path}
275-
className={`changes-item ${excludedFiles.has(file.path) ? 'excluded' : ''}`}
276-
onClick={() => onFileClick(file.path)}
366+
className={`changes-item ${activeFilePath === file.path ? 'active' : ''} ${excludedFiles.has(file.path) ? 'excluded' : ''}`}
367+
onClick={() => {
368+
setActiveFilePath(file.path);
369+
onFileClick(file.path);
370+
}}
371+
role="option"
372+
aria-selected={activeFilePath === file.path}
277373
>
278374
<div className="changes-file-info">
279375
<div className="changes-file-main">

anycode/components/Search.css

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
overflow-y: scroll;
1010
padding: 4px;
1111
}
12+
13+
.search-results:focus,
14+
.search-results:focus-visible {
15+
outline: none;
16+
box-shadow: none;
17+
}
1218
.search-summary {
1319
display: flex;
1420
align-items: center;
@@ -144,6 +150,13 @@
144150
outline: none;
145151
}
146152

153+
.search-button:focus-visible {
154+
opacity: 1;
155+
background-color: #3c3c3c;
156+
box-shadow: inset 0 0 0 1px #7a7a7a;
157+
outline: none;
158+
}
159+
147160
.search-button:active {
148161
opacity: 0.5;
149162
outline: none;
@@ -195,6 +208,17 @@
195208
border-radius: 6px;
196209
}
197210

211+
.file-path[data-active='true'] {
212+
background-color: #3c3c3c;
213+
color: #fff;
214+
border-radius: 6px;
215+
}
216+
217+
.search-results:focus .file-path[data-active='true'],
218+
.search-results:focus-visible .file-path[data-active='true'] {
219+
box-shadow: inset 0 0 0 1px #7a7a7a;
220+
}
221+
198222
.file-arrow {
199223
display: inline-block;
200224
margin-right: 6px;
@@ -272,6 +296,22 @@
272296
background-color: rgba(92, 92, 92, 0.977);
273297
border-radius: 4px;
274298
}
299+
300+
.search-item[data-active='true'] {
301+
background-color: #3c3c3c;
302+
color: #fff;
303+
border-radius: 4px;
304+
}
305+
306+
.search-item[data-active='true']:hover {
307+
background-color: #3c3c3c;
308+
color: #fff;
309+
}
310+
311+
.search-results:focus .search-item[data-active='true'],
312+
.search-results:focus-visible .search-item[data-active='true'] {
313+
box-shadow: inset 0 0 0 1px #7a7a7a;
314+
}
275315
.search-item strong {
276316
color: #888;
277317
}

0 commit comments

Comments
 (0)