fix(ui): stabilize file filter focus (#373)

## Summary
- Builds on #353 by @pascalandr, preserving the file tab path-copying
work and related inline file-list fixes.
- Moves the file filter row above the file list header so the list
content appears below the filter.
- Stabilizes the file filter input by using memoized file-list
derivations and a stable `FileList` component, and prevents the prompt
type-to-focus handler from stealing focus from editable event targets.

## Credits
Original feature work by @pascalandr in #353.

## Test Plan
- `npm run typecheck --workspace @codenomad/ui`

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
This commit is contained in:
Shantur Rathore
2026-04-26 15:31:25 +01:00
committed by GitHub
parent 2a25abce03
commit 2c7b81f812
9 changed files with 206 additions and 65 deletions

View File

@@ -1,9 +1,11 @@
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
import { For, Show, Suspense, createEffect, createMemo, createSignal, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { RefreshCw, Save } from "lucide-solid"
import { Copy, RefreshCw, Save, Search } from "lucide-solid"
import SplitFilePanel from "../components/SplitFilePanel"
import { copyToClipboard } from "../../../../../lib/clipboard"
import { showToastNotification } from "../../../../../lib/notifications"
const LazyMonacoFileViewer = lazy(() =>
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
@@ -42,6 +44,40 @@ interface FilesTabProps {
}
const FilesTab: Component<FilesTabProps> = (props) => {
const [filterQuery, setFilterQuery] = createSignal("")
createEffect(() => {
props.browserPath()
setFilterQuery("")
})
const sortedEntries = createMemo(() => {
const entries = props.browserEntries() || []
return [...entries].sort((a, b) => {
const aDir = a.type === "directory" ? 0 : 1
const bDir = b.type === "directory" ? 0 : 1
if (aDir !== bDir) return aDir - bDir
return String(a.name || "").localeCompare(String(b.name || ""))
})
})
const normalizedQuery = createMemo(() => filterQuery().trim().toLowerCase())
const filteredEntries = createMemo(() => {
const query = normalizedQuery()
const entries = sortedEntries()
if (!query) return entries
return entries.filter((item) => {
const name = String(item.name || "").toLowerCase()
return name.includes(query)
})
})
const initialListLoading = () => props.browserLoading() && props.browserEntries() === null
const listEmptyMessage = () =>
normalizedQuery() ? props.t("instanceShell.filesShell.search.empty") : props.t("instanceShell.filesShell.listEmpty")
const handleSave = () => {
const content = props.browserSelectedContent()
if (content !== undefined && content !== null) {
@@ -49,22 +85,108 @@ const FilesTab: Component<FilesTabProps> = (props) => {
}
}
const renderContent = (): JSX.Element => {
const entriesValue = props.browserEntries()
const entries = entriesValue || []
const sorted = [...entries].sort((a, b) => {
const aDir = a.type === "directory" ? 0 : 1
const bDir = b.type === "directory" ? 0 : 1
if (aDir !== bDir) return aDir - bDir
return String(a.name || "").localeCompare(String(b.name || ""))
const handleCopyPath = async (path: string, event?: MouseEvent) => {
event?.stopPropagation()
const ok = await copyToClipboard(path)
showToastNotification({
message: ok ? props.t("instanceShell.filesShell.toast.copyPathSuccess") : props.t("instanceShell.filesShell.toast.copyPathError"),
variant: ok ? "success" : "error",
})
}
const parent = props.parentPath()
const FileList: Component = () => (
<>
<div class="px-2 py-2 border-b border-base">
<div class="selector-input-group">
<div class="flex items-center gap-2 px-3 text-muted">
<Search class="w-4 h-4" />
</div>
<input
type="text"
value={filterQuery()}
onInput={(event) => setFilterQuery(event.currentTarget.value)}
placeholder={props.t("instanceShell.filesShell.search.placeholder")}
aria-label={props.t("instanceShell.filesShell.search.ariaLabel")}
class="selector-input"
/>
</div>
</div>
<div class="file-list-header">
<span class="file-list-title">{props.t("instanceShell.filesShell.fileListTitle")}</span>
<span class="file-list-count">{filteredEntries().length}</span>
</div>
<Show when={props.parentPath()}>
{(p) => (
<div class="file-list-item" onClick={() => props.onLoadEntries(p())}>
<div class="file-list-item-content">
<div class="file-list-item-path" title={p()}>
<span class="file-path-text">..</span>
</div>
</div>
</div>
)}
</Show>
<Show when={initialListLoading()}>
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
</Show>
<Show
when={!props.browserError() && !initialListLoading() && filteredEntries().length > 0}
fallback={
!initialListLoading()
? props.browserError()
? <div class="p-3 text-xs text-error">{props.browserError()}</div>
: <div class="p-3 text-xs text-secondary">{listEmptyMessage()}</div>
: undefined
}
>
<For each={filteredEntries()}>
{(item) => (
<div
class={`file-list-item ${props.browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
if (item.type === "directory") {
props.onLoadEntries(item.path)
return
}
props.onRequestOpenFile(item.path)
}}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.name}</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<div class="file-list-item-stats">
<span class="text-[10px] text-secondary">{item.type}</span>
</div>
<button
type="button"
class="git-change-row-action"
title={props.t("instanceShell.filesShell.actions.copyPath")}
aria-label={props.t("instanceShell.filesShell.actions.copyPath")}
onClick={(event) => void handleCopyPath(item.path, event)}
>
<Copy class="w-3 h-3" />
</button>
</div>
</div>
</div>
)}
</For>
</Show>
</>
)
const renderContent = (): JSX.Element => {
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
const emptyViewerMessage = () => {
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
if (initialListLoading()) return props.t("instanceInfo.loading")
return props.t("instanceShell.filesShell.viewerEmpty")
}
@@ -125,51 +247,6 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</div>
)
const renderList = () => (
<>
<Show when={parent}>
{(p) => (
<div class="file-list-item" onClick={() => props.onLoadEntries(p())}>
<div class="file-list-item-content">
<div class="file-list-item-path" title={p()}>
<span class="file-path-text">..</span>
</div>
</div>
</div>
)}
</Show>
<Show when={props.browserLoading() && entriesValue === null}>
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
</Show>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${props.browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
if (item.type === "directory") {
props.onLoadEntries(item.path)
return
}
props.onRequestOpenFile(item.path)
}}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.name}</span>
</div>
<div class="file-list-item-stats">
<span class="text-[10px] text-secondary">{item.type}</span>
</div>
</div>
</div>
)}
</For>
</>
)
return (
<SplitFilePanel
header={
@@ -210,7 +287,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</button>
</>
}
list={{ panel: renderList, overlay: renderList }}
list={{ panel: () => <FileList />, overlay: () => <FileList /> }}
viewer={renderViewer()}
listOpen={props.listOpen()}
onToggleList={props.onToggleList}
@@ -226,4 +303,4 @@ const FilesTab: Component<FilesTabProps> = (props) => {
return <>{renderContent()}</>
}
export default FilesTab
export default FilesTab

View File

@@ -232,14 +232,29 @@ export default function PromptInput(props: PromptInputProps) {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | null
const targetElement = e.target instanceof HTMLElement ? e.target : null
const isInputElement =
activeElement?.tagName === "INPUT" ||
activeElement?.tagName === "TEXTAREA" ||
activeElement?.tagName === "SELECT" ||
Boolean(activeElement?.isContentEditable)
const isEditableElement = (element: HTMLElement | null) =>
element?.tagName === "INPUT" ||
element?.tagName === "TEXTAREA" ||
element?.tagName === "SELECT" ||
Boolean(element?.isContentEditable)
if (isInputElement) return
const isInteractiveElement = (element: HTMLElement | null) =>
Boolean(
element?.closest(
'button, a[href], summary, [role="button"], [role="link"], [role="menuitem"], [role="option"], [role="tab"], [tabindex]:not([tabindex="-1"])',
),
)
if (
isEditableElement(activeElement) ||
isEditableElement(targetElement) ||
isInteractiveElement(activeElement) ||
isInteractiveElement(targetElement)
) {
return
}
const isModifierKey = e.ctrlKey || e.metaKey || e.altKey
if (isModifierKey) return

View File

@@ -149,8 +149,15 @@ export const instanceMessages = {
"instanceShell.filesShell.viewerTitle": "Change viewer",
"instanceShell.filesShell.viewerPlaceholder": "Detailed change rendering will be added in the next step.",
"instanceShell.filesShell.viewerEmpty": "No file selected.",
"instanceShell.filesShell.listEmpty": "No files in this folder.",
"instanceShell.filesShell.hideFiles": "Hide files",
"instanceShell.filesShell.showFiles": "Show files",
"instanceShell.filesShell.search.placeholder": "Filter files in this folder",
"instanceShell.filesShell.search.ariaLabel": "Filter files in this folder",
"instanceShell.filesShell.search.empty": "No matching files.",
"instanceShell.filesShell.actions.copyPath": "Copy path",
"instanceShell.filesShell.toast.copyPathSuccess": "Copied path",
"instanceShell.filesShell.toast.copyPathError": "Failed to copy path",
"instanceShell.diff.hideUnchanged": "Hide unchanged regions",
"instanceShell.diff.showFull": "Show full file",
"instanceShell.diff.switchToSplit": "Switch to split view",

View File

@@ -148,6 +148,13 @@ export const instanceMessages = {
"instanceShell.filesShell.viewerTitle": "Visor de cambios",
"instanceShell.filesShell.viewerPlaceholder": "La vista detallada se agregará en el siguiente paso.",
"instanceShell.filesShell.viewerEmpty": "Ningún archivo seleccionado.",
"instanceShell.filesShell.listEmpty": "No hay archivos en esta carpeta.",
"instanceShell.filesShell.search.placeholder": "Filtrar archivos de esta carpeta",
"instanceShell.filesShell.search.ariaLabel": "Filtrar archivos de esta carpeta",
"instanceShell.filesShell.search.empty": "No hay archivos coincidentes.",
"instanceShell.filesShell.actions.copyPath": "Copiar ruta",
"instanceShell.filesShell.toast.copyPathSuccess": "Ruta copiada",
"instanceShell.filesShell.toast.copyPathError": "No se pudo copiar la ruta",
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
"instanceShell.plan.empty": "Aún no hay nada planificado.",

View File

@@ -148,6 +148,13 @@ export const instanceMessages = {
"instanceShell.filesShell.viewerTitle": "Visionneuse de changements",
"instanceShell.filesShell.viewerPlaceholder": "Le rendu détaillé sera ajouté à l'étape suivante.",
"instanceShell.filesShell.viewerEmpty": "Aucun fichier sélectionné.",
"instanceShell.filesShell.listEmpty": "Aucun fichier dans ce dossier.",
"instanceShell.filesShell.search.placeholder": "Filtrer les fichiers de ce dossier",
"instanceShell.filesShell.search.ariaLabel": "Filtrer les fichiers de ce dossier",
"instanceShell.filesShell.search.empty": "Aucun fichier correspondant.",
"instanceShell.filesShell.actions.copyPath": "Copier le chemin",
"instanceShell.filesShell.toast.copyPathSuccess": "Chemin copié",
"instanceShell.filesShell.toast.copyPathError": "Impossible de copier le chemin",
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
"instanceShell.plan.empty": "Aucun plan pour l'instant.",

View File

@@ -133,8 +133,15 @@ export const instanceMessages = {
"instanceShell.filesShell.viewerTitle": "מציג שינויים",
"instanceShell.filesShell.viewerPlaceholder": "תצוגת שינויים מפורטת תתווסף בשלב הבא.",
"instanceShell.filesShell.viewerEmpty": "לא נבחר קובץ.",
"instanceShell.filesShell.listEmpty": "אין קבצים בתיקייה הזו.",
"instanceShell.filesShell.hideFiles": "הסתר קבצים",
"instanceShell.filesShell.showFiles": "הצג קבצים",
"instanceShell.filesShell.search.placeholder": "סנן קבצים בתיקייה הזו",
"instanceShell.filesShell.search.ariaLabel": "סנן קבצים בתיקייה הזו",
"instanceShell.filesShell.search.empty": "לא נמצאו קבצים תואמים.",
"instanceShell.filesShell.actions.copyPath": "העתק נתיב",
"instanceShell.filesShell.toast.copyPathSuccess": "הנתיב הועתק",
"instanceShell.filesShell.toast.copyPathError": "העתקת הנתיב נכשלה",
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",

View File

@@ -148,6 +148,13 @@ export const instanceMessages = {
"instanceShell.filesShell.viewerTitle": "変更ビューア",
"instanceShell.filesShell.viewerPlaceholder": "詳細な変更表示は次のステップで追加します。",
"instanceShell.filesShell.viewerEmpty": "ファイルが選択されていません。",
"instanceShell.filesShell.listEmpty": "このフォルダーにファイルはありません。",
"instanceShell.filesShell.search.placeholder": "このフォルダーのファイルを絞り込む",
"instanceShell.filesShell.search.ariaLabel": "このフォルダーのファイルを絞り込む",
"instanceShell.filesShell.search.empty": "一致するファイルがありません。",
"instanceShell.filesShell.actions.copyPath": "パスをコピー",
"instanceShell.filesShell.toast.copyPathSuccess": "パスをコピーしました",
"instanceShell.filesShell.toast.copyPathError": "パスをコピーできませんでした",
"instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。",
"instanceShell.plan.empty": "まだ計画はありません。",

View File

@@ -148,6 +148,13 @@ export const instanceMessages = {
"instanceShell.filesShell.viewerTitle": "Просмотр изменений",
"instanceShell.filesShell.viewerPlaceholder": "Подробный рендер изменений будет добавлен на следующем этапе.",
"instanceShell.filesShell.viewerEmpty": "Файл не выбран.",
"instanceShell.filesShell.listEmpty": "В этой папке нет файлов.",
"instanceShell.filesShell.search.placeholder": "Фильтровать файлы в этой папке",
"instanceShell.filesShell.search.ariaLabel": "Фильтровать файлы в этой папке",
"instanceShell.filesShell.search.empty": "Совпадающих файлов нет.",
"instanceShell.filesShell.actions.copyPath": "Скопировать путь",
"instanceShell.filesShell.toast.copyPathSuccess": "Путь скопирован",
"instanceShell.filesShell.toast.copyPathError": "Не удалось скопировать путь",
"instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.",
"instanceShell.plan.empty": "Пока ничего не запланировано.",

View File

@@ -148,6 +148,13 @@ export const instanceMessages = {
"instanceShell.filesShell.viewerTitle": "更改查看器",
"instanceShell.filesShell.viewerPlaceholder": "详细更改渲染将在下一步中添加。",
"instanceShell.filesShell.viewerEmpty": "未选择文件。",
"instanceShell.filesShell.listEmpty": "此文件夹中没有文件。",
"instanceShell.filesShell.search.placeholder": "筛选此文件夹中的文件",
"instanceShell.filesShell.search.ariaLabel": "筛选此文件夹中的文件",
"instanceShell.filesShell.search.empty": "没有匹配的文件。",
"instanceShell.filesShell.actions.copyPath": "复制路径",
"instanceShell.filesShell.toast.copyPathSuccess": "路径已复制",
"instanceShell.filesShell.toast.copyPathError": "无法复制路径",
"instanceShell.plan.noSessionSelected": "选择会话以查看计划。",
"instanceShell.plan.empty": "暂无计划。",