mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
fix(ssh): move through the remote folder browser with the arrow keys (#33914)
* fix(ssh): move through the remote folder browser with the arrow keys The browser had no highlighted row and its filter box ignored Up/Down, so the keys only scrolled the list. The first entry is now highlighted, the arrows move it into view, and Enter opens it. A filter that leaves one folder still starts on that folder, and a fully typed path starts on no row so Enter still goes to that path. Also drops the duplicated "No matches" line. Fixes #31555 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Name the remote folder listbox and point at no row while a listing loads While a listing loads only the spinner renders, so the highlight and aria-activedescendant had been left on rows that were no longer there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep Enter and the highlight off rows that are about to be replaced While the committed listing loads, Enter fell back to the previous listing's entries and could open a folder from it. While a path preview resolves, its prior rows stay visible but are no longer targetable, so the highlight and aria-activedescendant now clear. The filter input also gets combobox semantics and a name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ssh): drop a moved highlight when a typed path previews the same listing * fix(ssh): reset the folder highlight per keystroke and hold Enter until a typed path resolves * fix(ssh): hold Enter on typed path input before its first preview --------- Co-authored-by: Younghwan Chae <chyohw97@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Younghwan Chae
parent
94f562e634
commit
99631994bc
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteFileBrowser } from './RemoteFileBrowser'
|
||||
|
||||
const browseDir = vi.fn(async ({ dirPath }: { dirPath: string }) => ({
|
||||
entries: [
|
||||
{ name: 'docs', isDirectory: true },
|
||||
{ name: 'src', isDirectory: true },
|
||||
{ name: 'README.md', isDirectory: false }
|
||||
],
|
||||
resolvedPath: dirPath === '~' ? '/home/alice' : dirPath,
|
||||
pathFlavor: 'posix' as const
|
||||
}))
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async function render(): Promise<{
|
||||
container: HTMLDivElement
|
||||
input: HTMLInputElement
|
||||
root: Root
|
||||
}> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(<RemoteFileBrowser targetId="target-1" onSelect={vi.fn()} onCancel={vi.fn()} />)
|
||||
await flushPromises()
|
||||
})
|
||||
const input = container.querySelector('input')
|
||||
if (!input) {
|
||||
throw new Error('Remote file browser input was not rendered')
|
||||
}
|
||||
return { container, input, root }
|
||||
}
|
||||
|
||||
async function press(
|
||||
input: HTMLInputElement,
|
||||
key: string,
|
||||
modifiers: KeyboardEventInit = {}
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...modifiers })
|
||||
)
|
||||
await flushPromises()
|
||||
})
|
||||
}
|
||||
|
||||
async function type(input: HTMLInputElement, value: string): Promise<void> {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
async function settlePathResolve(): Promise<void> {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
await flushPromises()
|
||||
})
|
||||
}
|
||||
|
||||
function selectedRow(container: HTMLElement): string | null | undefined {
|
||||
return container.querySelector('[role="option"][aria-selected="true"]')?.textContent
|
||||
}
|
||||
|
||||
describe('RemoteFileBrowser keyboard navigation', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
browseDir.mockClear()
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { ssh: { browseDir } } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.replaceChildren()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('highlights the first row and moves it with the arrow keys', async () => {
|
||||
const { container, input, root } = await render()
|
||||
expect(selectedRow(container)).toBe('docs')
|
||||
expect(input.getAttribute('aria-activedescendant')).toBe(
|
||||
container.querySelector('[role="option"][aria-selected="true"]')?.id
|
||||
)
|
||||
expect(input.getAttribute('role')).toBe('combobox')
|
||||
expect(input.getAttribute('aria-controls')).toBe(
|
||||
container.querySelector('[role="listbox"]')?.id
|
||||
)
|
||||
|
||||
await press(input, 'ArrowDown')
|
||||
await press(input, 'ArrowDown')
|
||||
await press(input, 'ArrowDown')
|
||||
expect(selectedRow(container)).toBe('README.md')
|
||||
|
||||
await press(input, 'ArrowUp')
|
||||
expect(selectedRow(container)).toBe('src')
|
||||
|
||||
// Modified arrows stay with the input (selection, caret jumps).
|
||||
await press(input, 'ArrowDown', { shiftKey: true })
|
||||
expect(selectedRow(container)).toBe('src')
|
||||
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
|
||||
it('opens the highlighted folder on Enter', async () => {
|
||||
const { container, input, root } = await render()
|
||||
browseDir.mockClear()
|
||||
|
||||
await press(input, 'ArrowDown')
|
||||
await press(input, 'Enter')
|
||||
|
||||
expect(browseDir).toHaveBeenCalledWith({ targetId: 'target-1', dirPath: '/home/alice/src' })
|
||||
expect(selectedRow(container)).toBe('docs')
|
||||
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
|
||||
it('points the input at no row while the next listing loads', async () => {
|
||||
const { input, root } = await render()
|
||||
browseDir.mockImplementationOnce(() => new Promise(() => {}))
|
||||
|
||||
await press(input, 'Enter')
|
||||
|
||||
expect(input.hasAttribute('aria-activedescendant')).toBe(false)
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
|
||||
// Why: the previous listing's single folder would otherwise win Enter while the next one loads.
|
||||
it('ignores Enter while the next listing loads', async () => {
|
||||
browseDir.mockImplementationOnce(async () => ({
|
||||
entries: [
|
||||
{ name: 'docs', isDirectory: true },
|
||||
{ name: 'README.md', isDirectory: false }
|
||||
],
|
||||
resolvedPath: '/home/alice',
|
||||
pathFlavor: 'posix' as const
|
||||
}))
|
||||
const { input, root } = await render()
|
||||
browseDir.mockClear()
|
||||
browseDir.mockImplementationOnce(() => new Promise(() => {}))
|
||||
|
||||
await press(input, 'Enter')
|
||||
await press(input, 'Enter')
|
||||
|
||||
expect(browseDir).toHaveBeenCalledTimes(1)
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
|
||||
// Why: the cached home listing comes back as the same array under `~/`, which must not keep the moved row.
|
||||
it('commits a typed path instead of a row highlighted before typing it', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { input, root } = await render()
|
||||
await press(input, 'ArrowDown')
|
||||
browseDir.mockClear()
|
||||
|
||||
await type(input, '~/')
|
||||
await settlePathResolve()
|
||||
await press(input, 'Enter')
|
||||
|
||||
// Committing `~/` reopens the cached home listing and clears the typed path.
|
||||
expect(input.value).toBe('')
|
||||
expect(browseDir).not.toHaveBeenCalledWith({
|
||||
targetId: 'target-1',
|
||||
dirPath: '/home/alice/src'
|
||||
})
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
|
||||
// Why: until the debounce resolves `docs/`, the preview and its moved highlight still show `~/`.
|
||||
it('ignores Enter on a typed directory that has not resolved yet', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { input, root } = await render()
|
||||
await type(input, '~/')
|
||||
await settlePathResolve()
|
||||
await press(input, 'ArrowDown')
|
||||
await press(input, 'ArrowDown')
|
||||
browseDir.mockClear()
|
||||
|
||||
await type(input, '~/docs/')
|
||||
await press(input, 'Enter')
|
||||
expect(browseDir).not.toHaveBeenCalled()
|
||||
|
||||
await settlePathResolve()
|
||||
await press(input, 'Enter')
|
||||
expect(browseDir).toHaveBeenLastCalledWith({
|
||||
targetId: 'target-1',
|
||||
dirPath: '/home/alice/docs'
|
||||
})
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
|
||||
// Why: before the first preview, `.` still filters the old rows, so the default highlight is a dotfolder.
|
||||
it('ignores Enter on path input that has no preview yet', async () => {
|
||||
vi.useFakeTimers()
|
||||
browseDir.mockImplementationOnce(async () => ({
|
||||
entries: [
|
||||
{ name: '.config', isDirectory: true },
|
||||
{ name: '.ssh', isDirectory: true }
|
||||
],
|
||||
resolvedPath: '/home/alice',
|
||||
pathFlavor: 'posix' as const
|
||||
}))
|
||||
const { input, root } = await render()
|
||||
browseDir.mockClear()
|
||||
|
||||
await type(input, '.')
|
||||
await press(input, 'Enter')
|
||||
|
||||
expect(browseDir).not.toHaveBeenCalled()
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,14 @@
|
||||
import { ImeInput } from '@/lib/ime-text-field'
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { LoaderCircle, Search } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { filterEntries, joinPath, parentPath, type DirEntry } from './remote-file-browser-helpers'
|
||||
import { RemoteFileBrowserBreadcrumbs } from './RemoteFileBrowserBreadcrumbs'
|
||||
import { RemoteFileBrowserEntryList } from './RemoteFileBrowserEntryList'
|
||||
import { initialRemoteFileBrowserHighlight } from './remote-file-browser-highlight'
|
||||
import type { PreviewState } from './remote-file-browser-path-preview-resolver'
|
||||
import { useRemoteFileBrowserFilterKeyCommands } from './use-remote-file-browser-filter-key-commands'
|
||||
import { useRemoteFileBrowserListing } from './use-remote-file-browser-listing'
|
||||
import { useRemoteFileBrowserPathPreview } from './use-remote-file-browser-path-preview'
|
||||
@@ -24,6 +26,7 @@ type RemoteFileBrowserProps = (
|
||||
}
|
||||
|
||||
const FILE_HINT_MS = 2000
|
||||
const NO_ENTRIES: readonly DirEntry[] = []
|
||||
const FILE_HINT_TEXT = "Files can't be opened as a project"
|
||||
|
||||
export function RemoteFileBrowser({
|
||||
@@ -40,6 +43,15 @@ export function RemoteFileBrowser({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [fileHint, setFileHint] = useState(false)
|
||||
// Tied to the list, preview and text it was moved on, so any new listing, preview or keystroke starts
|
||||
// from its own default (a cached listing reappears as the same array; a typed path waits on a debounce).
|
||||
const [highlight, setHighlight] = useState<{
|
||||
entries: readonly DirEntry[]
|
||||
preview: PreviewState | null
|
||||
filter: string
|
||||
index: number
|
||||
} | null>(null)
|
||||
const listboxId = useId()
|
||||
const genRef = useRef(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const fileHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -61,7 +73,8 @@ export function RemoteFileBrowser({
|
||||
handleInputPaste,
|
||||
discardPreview,
|
||||
resetPreviewForNavigation,
|
||||
cancelPreviewWork
|
||||
cancelPreviewWork,
|
||||
previewMatchesInput
|
||||
} = useRemoteFileBrowserPathPreview({
|
||||
resolvedPath,
|
||||
pathFlavor,
|
||||
@@ -156,6 +169,24 @@ export function RemoteFileBrowser({
|
||||
}, [resolvedPath, navigate, pathFlavor])
|
||||
|
||||
const filteredEntries = useMemo(() => filterEntries(entries, filter), [entries, filter])
|
||||
const previewFilteredEntries = useMemo(
|
||||
() => (preview ? filterEntries(preview.entries, preview.filter) : null),
|
||||
[preview]
|
||||
)
|
||||
// Why: while a listing loads only the spinner renders, so the prior rows can't be targeted.
|
||||
const displayEntries = loading ? NO_ENTRIES : (previewFilteredEntries ?? filteredEntries)
|
||||
// Why: a resolving preview keeps its prior rows on screen, but they are no longer targetable.
|
||||
const activeIndex = preview?.loading
|
||||
? -1
|
||||
: highlight?.entries === displayEntries &&
|
||||
highlight.preview === preview &&
|
||||
highlight.filter === filter
|
||||
? highlight.index
|
||||
: initialRemoteFileBrowserHighlight(displayEntries, preview !== null && preview.filter === '')
|
||||
const moveHighlight = useCallback(
|
||||
(index: number) => setHighlight({ entries: displayEntries, preview, filter, index }),
|
||||
[displayEntries, preview, filter]
|
||||
)
|
||||
|
||||
const triggerFileHint = useCallback(() => {
|
||||
if (fileHintTimerRef.current) {
|
||||
@@ -211,10 +242,16 @@ export function RemoteFileBrowser({
|
||||
)
|
||||
|
||||
const handleFilterKeyDown = useRemoteFileBrowserFilterKeyCommands({
|
||||
loading,
|
||||
filter,
|
||||
setFilter,
|
||||
filteredEntries,
|
||||
displayEntries,
|
||||
activeIndex,
|
||||
moveHighlight,
|
||||
listParentPath,
|
||||
preview,
|
||||
previewMatchesInput,
|
||||
discardPreview,
|
||||
resolvedPath,
|
||||
pathFlavor,
|
||||
@@ -247,6 +284,13 @@ export function RemoteFileBrowser({
|
||||
<ImeInput
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded
|
||||
aria-autocomplete="list"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.RemoteFileBrowser.inputLabel',
|
||||
'Filter or path'
|
||||
)}
|
||||
autoFocus
|
||||
value={filter}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
@@ -256,6 +300,8 @@ export function RemoteFileBrowser({
|
||||
'auto.components.sidebar.RemoteFileBrowser.2300612806',
|
||||
'Type to filter or enter a path…'
|
||||
)}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `${listboxId}-${activeIndex}` : undefined}
|
||||
aria-invalid={!!preview?.error}
|
||||
aria-describedby={preview?.error ? 'remote-file-browser-path-error' : undefined}
|
||||
className={cn(
|
||||
@@ -283,7 +329,9 @@ export function RemoteFileBrowser({
|
||||
loading={loading}
|
||||
error={error}
|
||||
entries={entries}
|
||||
filteredEntries={filteredEntries}
|
||||
displayEntries={displayEntries}
|
||||
activeIndex={activeIndex}
|
||||
listboxId={listboxId}
|
||||
filter={filter}
|
||||
preview={preview}
|
||||
isPreviewActive={isPreviewActive}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type React from 'react'
|
||||
import { useMemo, type RefObject } from 'react'
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
import { ChevronRight, Folder, LoaderCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FileTypeIcon } from '@/components/FileTypeIcon'
|
||||
import {
|
||||
filterEntries,
|
||||
isRemoteFileBrowserPathResolveTextTooLarge,
|
||||
type DirEntry
|
||||
} from './remote-file-browser-helpers'
|
||||
@@ -15,7 +14,9 @@ type RemoteFileBrowserEntryListProps = {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
entries: DirEntry[]
|
||||
filteredEntries: DirEntry[]
|
||||
displayEntries: readonly DirEntry[]
|
||||
activeIndex: number
|
||||
listboxId: string
|
||||
filter: string
|
||||
preview: PreviewState | null
|
||||
isPreviewActive: boolean
|
||||
@@ -28,7 +29,9 @@ export function RemoteFileBrowserEntryList({
|
||||
loading,
|
||||
error,
|
||||
entries,
|
||||
filteredEntries,
|
||||
displayEntries,
|
||||
activeIndex,
|
||||
listboxId,
|
||||
filter,
|
||||
preview,
|
||||
isPreviewActive,
|
||||
@@ -36,12 +39,17 @@ export function RemoteFileBrowserEntryList({
|
||||
handleRowClick,
|
||||
handleRowDoubleClick
|
||||
}: RemoteFileBrowserEntryListProps): React.JSX.Element {
|
||||
const previewFilteredEntries = useMemo(
|
||||
() => (preview ? filterEntries(preview.entries, preview.filter) : []),
|
||||
[preview]
|
||||
)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0) {
|
||||
return
|
||||
}
|
||||
// Optional call: happy-dom has no scrollIntoView.
|
||||
scrollRef.current
|
||||
?.querySelector(`[data-entry-index="${activeIndex}"]`)
|
||||
?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [activeIndex, displayEntries])
|
||||
|
||||
const displayEntries = isPreviewActive ? previewFilteredEntries : filteredEntries
|
||||
const displayEmptyDirCopy = isPreviewActive
|
||||
? `${preview!.resolvedPath} is empty`
|
||||
: 'Empty directory'
|
||||
@@ -59,7 +67,16 @@ export function RemoteFileBrowserEntryList({
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-md overflow-hidden bg-background">
|
||||
<div className="h-[240px] overflow-y-auto scrollbar-sleek">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.RemoteFileBrowser.entriesLabel',
|
||||
'Folder entries'
|
||||
)}
|
||||
className="h-[240px] overflow-y-auto scrollbar-sleek"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
|
||||
@@ -85,13 +102,19 @@ export function RemoteFileBrowserEntryList({
|
||||
// Filtered-out entries need different copy from a directory that is genuinely empty.
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-xs text-muted-foreground">{displayNoMatchesCopy}</p>
|
||||
<p className="text-xs text-muted-foreground">{displayNoMatchesCopy}</p>
|
||||
</div>
|
||||
) : (
|
||||
displayEntries.map((entry) => {
|
||||
displayEntries.map((entry, index) => {
|
||||
const isActive = index === activeIndex
|
||||
return (
|
||||
<button
|
||||
key={entry.name}
|
||||
id={`${listboxId}-${index}`}
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
data-selected={isActive}
|
||||
data-entry-index={index}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
onClick={() => handleRowClick(entry)}
|
||||
onDoubleClick={() => handleRowDoubleClick(entry)}
|
||||
@@ -100,7 +123,8 @@ export function RemoteFileBrowserEntryList({
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer',
|
||||
// Why `jump-palette-item`: flat accent is invisible as a keyboard cursor on light dialogs.
|
||||
'jump-palette-item w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer',
|
||||
'hover:bg-accent/60'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
initialRemoteFileBrowserHighlight,
|
||||
stepRemoteFileBrowserHighlight
|
||||
} from './remote-file-browser-highlight'
|
||||
|
||||
const folder = (name: string) => ({ name, isDirectory: true })
|
||||
const file = (name: string) => ({ name, isDirectory: false })
|
||||
|
||||
describe('initialRemoteFileBrowserHighlight', () => {
|
||||
it('starts on the first row', () => {
|
||||
expect(initialRemoteFileBrowserHighlight([folder('a'), folder('b')], false)).toBe(0)
|
||||
})
|
||||
|
||||
// Why: Enter on a filter that leaves one folder has always opened it, even past files.
|
||||
it('starts on the only folder a filter leaves', () => {
|
||||
expect(initialRemoteFileBrowserHighlight([file('src.md'), folder('src')], false)).toBe(1)
|
||||
})
|
||||
|
||||
it('starts on no row when the list is empty or the typed path is itself the target', () => {
|
||||
expect(initialRemoteFileBrowserHighlight([], false)).toBe(-1)
|
||||
expect(initialRemoteFileBrowserHighlight([folder('a')], true)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stepRemoteFileBrowserHighlight', () => {
|
||||
it('moves within the list and stops at both ends', () => {
|
||||
expect(stepRemoteFileBrowserHighlight(0, 1, 3)).toBe(1)
|
||||
expect(stepRemoteFileBrowserHighlight(2, 1, 3)).toBe(2)
|
||||
expect(stepRemoteFileBrowserHighlight(0, -1, 3)).toBe(0)
|
||||
})
|
||||
|
||||
it('enters the list from no row at the end the key points to', () => {
|
||||
expect(stepRemoteFileBrowserHighlight(-1, 1, 3)).toBe(0)
|
||||
expect(stepRemoteFileBrowserHighlight(-1, -1, 3)).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { decideEnterAction, type DirEntry } from './remote-file-browser-helpers'
|
||||
|
||||
/** -1 means no row: Enter then acts on the typed path, not a listed entry. */
|
||||
export function initialRemoteFileBrowserHighlight(
|
||||
entries: readonly DirEntry[],
|
||||
typedPathIsTarget: boolean
|
||||
): number {
|
||||
if (entries.length === 0 || typedPathIsTarget) {
|
||||
return -1
|
||||
}
|
||||
const action = decideEnterAction([...entries])
|
||||
return action.type === 'navigate' ? entries.findIndex((entry) => entry.name === action.name) : 0
|
||||
}
|
||||
|
||||
export function stepRemoteFileBrowserHighlight(
|
||||
index: number,
|
||||
delta: 1 | -1,
|
||||
length: number
|
||||
): number {
|
||||
if (length === 0) {
|
||||
return -1
|
||||
}
|
||||
if (index < 0) {
|
||||
return delta === 1 ? 0 : length - 1
|
||||
}
|
||||
return Math.min(Math.max(index + delta, 0), length - 1)
|
||||
}
|
||||
@@ -7,14 +7,22 @@ import {
|
||||
parsePathInput,
|
||||
type DirEntry
|
||||
} from './remote-file-browser-helpers'
|
||||
import { stepRemoteFileBrowserHighlight } from './remote-file-browser-highlight'
|
||||
import { isImeOwnedKeyboardEvent } from '@/lib/ime-composition-keyboard-event'
|
||||
import type { PreviewState } from './remote-file-browser-path-preview-resolver'
|
||||
import type { FilesystemPathFlavor } from '../../../../shared/filesystem-entry-types'
|
||||
|
||||
export type RemoteFileBrowserFilterKeyCommandsArgs = {
|
||||
loading: boolean
|
||||
filter: string
|
||||
setFilter: Dispatch<SetStateAction<string>>
|
||||
filteredEntries: DirEntry[]
|
||||
displayEntries: readonly DirEntry[]
|
||||
activeIndex: number
|
||||
moveHighlight: (index: number) => void
|
||||
listParentPath: string
|
||||
preview: PreviewState | null
|
||||
previewMatchesInput: (raw: string) => boolean
|
||||
discardPreview: () => void
|
||||
resolvedPath: string
|
||||
pathFlavor: FilesystemPathFlavor
|
||||
@@ -27,10 +35,16 @@ export type RemoteFileBrowserFilterKeyCommandsArgs = {
|
||||
}
|
||||
|
||||
export function useRemoteFileBrowserFilterKeyCommands({
|
||||
loading,
|
||||
filter,
|
||||
setFilter,
|
||||
filteredEntries,
|
||||
displayEntries,
|
||||
activeIndex,
|
||||
moveHighlight,
|
||||
listParentPath,
|
||||
preview,
|
||||
previewMatchesInput,
|
||||
discardPreview,
|
||||
resolvedPath,
|
||||
pathFlavor,
|
||||
@@ -43,12 +57,39 @@ export function useRemoteFileBrowserFilterKeyCommands({
|
||||
}: RemoteFileBrowserFilterKeyCommandsArgs): (e: KeyboardEvent<HTMLInputElement>) => void {
|
||||
return useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const modified = e.shiftKey || e.metaKey || e.ctrlKey || e.altKey
|
||||
if (
|
||||
(e.key === 'ArrowDown' || e.key === 'ArrowUp') &&
|
||||
!modified &&
|
||||
!isImeOwnedKeyboardEvent(e)
|
||||
) {
|
||||
if (displayEntries.length > 0 && !preview?.loading) {
|
||||
e.preventDefault()
|
||||
const delta = e.key === 'ArrowDown' ? 1 : -1
|
||||
moveHighlight(stepRemoteFileBrowserHighlight(activeIndex, delta, displayEntries.length))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (preview) {
|
||||
if (preview.error || preview.loading) {
|
||||
e.preventDefault()
|
||||
return
|
||||
// Why: until the debounce resolves typed path input, the rows on screen belong to another directory.
|
||||
const pathPending =
|
||||
parsePathInput(filter, pathFlavor).mode === 'path' &&
|
||||
(!preview || !previewMatchesInput(filter))
|
||||
if (preview?.error || preview?.loading || pathPending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const highlighted = displayEntries[activeIndex]
|
||||
if (highlighted) {
|
||||
e.preventDefault()
|
||||
if (highlighted.isDirectory) {
|
||||
navigate(joinPath(listParentPath, highlighted.name, pathFlavor))
|
||||
} else {
|
||||
triggerFileHint()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (preview) {
|
||||
const parsed = parsePathInput(filter, pathFlavor)
|
||||
// Without a trailing filter, commit the resolved preview directory itself.
|
||||
if (parsed.mode === 'path' && parsed.trailingFilter === '') {
|
||||
@@ -70,6 +111,11 @@ export function useRemoteFileBrowserFilterKeyCommands({
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: while the next listing loads, filteredEntries still holds the previous directory.
|
||||
if (loading) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const action = decideEnterAction(filteredEntries)
|
||||
if (action.type === 'navigate') {
|
||||
e.preventDefault()
|
||||
@@ -100,9 +146,15 @@ export function useRemoteFileBrowserFilterKeyCommands({
|
||||
}
|
||||
},
|
||||
[
|
||||
loading,
|
||||
filter,
|
||||
filteredEntries,
|
||||
displayEntries,
|
||||
activeIndex,
|
||||
moveHighlight,
|
||||
listParentPath,
|
||||
preview,
|
||||
previewMatchesInput,
|
||||
navigate,
|
||||
navigateInto,
|
||||
navigateUp,
|
||||
|
||||
@@ -43,6 +43,8 @@ export type RemoteFileBrowserPathPreview = {
|
||||
resetPreviewForNavigation: () => void
|
||||
/** Detach: invalidate in-flight preview work and clear its timers. */
|
||||
cancelPreviewWork: () => void
|
||||
/** False while a typed path's directory part still waits on the resolve debounce. */
|
||||
previewMatchesInput: (raw: string) => boolean
|
||||
}
|
||||
|
||||
export function useRemoteFileBrowserPathPreview({
|
||||
@@ -195,12 +197,18 @@ export function useRemoteFileBrowserPathPreview({
|
||||
[resolvePathInput, pathFlavor, inputRef]
|
||||
)
|
||||
|
||||
const previewMatchesInput = useCallback(
|
||||
(raw: string) => committedPrefix(raw) === lastCommittedPrefixRef.current,
|
||||
[]
|
||||
)
|
||||
|
||||
return {
|
||||
preview,
|
||||
handleInputChange,
|
||||
handleInputPaste,
|
||||
discardPreview,
|
||||
resetPreviewForNavigation,
|
||||
cancelPreviewWork
|
||||
cancelPreviewWork,
|
||||
previewMatchesInput
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5944,7 +5944,9 @@
|
||||
"51001182e3": "Empty directory",
|
||||
"971d85cc84": "Opens as a project on this host · {{value0}}",
|
||||
"00c4235c10": "No matches for '{{value0}}'",
|
||||
"largeInputNoMatches": "No matches for this long input"
|
||||
"largeInputNoMatches": "No matches for this long input",
|
||||
"entriesLabel": "Folder entries",
|
||||
"inputLabel": "Filter or path"
|
||||
},
|
||||
"SleepTwinWorkspaceDialog": {
|
||||
"title": "Sleep Workspace",
|
||||
|
||||
@@ -5582,7 +5582,9 @@
|
||||
"51001182e3": "Directorio vacío",
|
||||
"971d85cc84": "Se abre como proyecto en este host · {{value0}}",
|
||||
"00c4235c10": "No hay coincidencias para '{{value0}}'",
|
||||
"largeInputNoMatches": "No hay coincidencias para esta entrada larga"
|
||||
"largeInputNoMatches": "No hay coincidencias para esta entrada larga",
|
||||
"entriesLabel": "Entradas de la carpeta",
|
||||
"inputLabel": "Filtro o ruta"
|
||||
},
|
||||
"SleepTwinWorkspaceDialog": {
|
||||
"title": "Sleep Workspace",
|
||||
|
||||
@@ -5745,7 +5745,9 @@
|
||||
"51001182e3": "Répertoire vide",
|
||||
"971d85cc84": "S'ouvre comme projet sur cet hôte · {{value0}}",
|
||||
"00c4235c10": "Aucun résultat pour « {{value0}} »",
|
||||
"largeInputNoMatches": "Aucun résultat pour cette saisie longue"
|
||||
"largeInputNoMatches": "Aucun résultat pour cette saisie longue",
|
||||
"entriesLabel": "Éléments du dossier",
|
||||
"inputLabel": "Filtre ou chemin"
|
||||
},
|
||||
"SleepTwinWorkspaceDialog": {
|
||||
"title": "Sleep Workspace",
|
||||
|
||||
@@ -5548,7 +5548,9 @@
|
||||
"51001182e3": "空のディレクトリ",
|
||||
"971d85cc84": "このホスト上のプロジェクトとして開きます · {{value0}}",
|
||||
"00c4235c10": "「{{value0}}」に一致するものはありません",
|
||||
"largeInputNoMatches": "この長い入力に一致するものはありません"
|
||||
"largeInputNoMatches": "この長い入力に一致するものはありません",
|
||||
"entriesLabel": "フォルダーの項目",
|
||||
"inputLabel": "フィルターまたはパス"
|
||||
},
|
||||
"SleepTwinWorkspaceDialog": {
|
||||
"title": "Sleep Workspace",
|
||||
|
||||
@@ -5548,7 +5548,9 @@
|
||||
"51001182e3": "빈 디렉터리",
|
||||
"971d85cc84": "이 호스트에서 프로젝트로 열립니다 · {{value0}}",
|
||||
"00c4235c10": "'{{value0}}'과(와) 일치하는 항목이 없습니다.",
|
||||
"largeInputNoMatches": "이 긴 입력과 일치하는 항목 없음"
|
||||
"largeInputNoMatches": "이 긴 입력과 일치하는 항목 없음",
|
||||
"entriesLabel": "폴더 항목",
|
||||
"inputLabel": "필터 또는 경로"
|
||||
},
|
||||
"SleepTwinWorkspaceDialog": {
|
||||
"title": "Sleep Workspace",
|
||||
|
||||
@@ -5548,7 +5548,9 @@
|
||||
"51001182e3": "空目录",
|
||||
"971d85cc84": "作为远程项目打开 · {{value0}}",
|
||||
"00c4235c10": "没有匹配“{{value0}}”",
|
||||
"largeInputNoMatches": "没有匹配此长输入的结果"
|
||||
"largeInputNoMatches": "没有匹配此长输入的结果",
|
||||
"entriesLabel": "文件夹条目",
|
||||
"inputLabel": "筛选或路径"
|
||||
},
|
||||
"SleepTwinWorkspaceDialog": {
|
||||
"title": "Sleep Workspace",
|
||||
|
||||
Reference in New Issue
Block a user