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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 עדיין.",
|
||||
|
||||
@@ -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": "まだ計画はありません。",
|
||||
|
||||
@@ -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": "Пока ничего не запланировано.",
|
||||
|
||||
@@ -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": "暂无计划。",
|
||||
|
||||
Reference in New Issue
Block a user