diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx index 1cb4f68e..36b1156f 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx @@ -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 = (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 = (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 = () => ( + <> +
+
+
+ +
+ setFilterQuery(event.currentTarget.value)} + placeholder={props.t("instanceShell.filesShell.search.placeholder")} + aria-label={props.t("instanceShell.filesShell.search.ariaLabel")} + class="selector-input" + /> +
+
+
+ {props.t("instanceShell.filesShell.fileListTitle")} + {filteredEntries().length} +
+ + + {(p) => ( +
props.onLoadEntries(p())}> +
+
+ .. +
+
+
+ )} +
+ + +
{props.t("instanceInfo.loading")}
+
+ + 0} + fallback={ + !initialListLoading() + ? props.browserError() + ?
{props.browserError()}
+ :
{listEmptyMessage()}
+ : undefined + } + > + + {(item) => ( +
{ + if (item.type === "directory") { + props.onLoadEntries(item.path) + return + } + props.onRequestOpenFile(item.path) + }} + title={item.path} + > +
+
+ {item.name} +
+
+
+ {item.type} +
+ +
+
+
+ )} +
+
+ + ) + + 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 = (props) => { ) - const renderList = () => ( - <> - - {(p) => ( -
props.onLoadEntries(p())}> -
-
- .. -
-
-
- )} -
- - -
{props.t("instanceInfo.loading")}
-
- - - {(item) => ( -
{ - if (item.type === "directory") { - props.onLoadEntries(item.path) - return - } - props.onRequestOpenFile(item.path) - }} - title={item.path} - > -
-
- {item.name} -
-
- {item.type} -
-
-
- )} -
- - ) - return ( = (props) => { } - list={{ panel: renderList, overlay: renderList }} + list={{ panel: () => , overlay: () => }} viewer={renderViewer()} listOpen={props.listOpen()} onToggleList={props.onToggleList} @@ -226,4 +303,4 @@ const FilesTab: Component = (props) => { return <>{renderContent()} } -export default FilesTab \ No newline at end of file +export default FilesTab diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index b55ee07e..686fcc87 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -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 diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index 74142072..dc77f246 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index 37e5d198..1258bbf6 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -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.", diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index addf4c7d..342b172d 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -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.", diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts index c689d7dd..d757bce7 100644 --- a/packages/ui/src/lib/i18n/messages/he/instance.ts +++ b/packages/ui/src/lib/i18n/messages/he/instance.ts @@ -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 עדיין.", diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index 698b2691..628d74fe 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -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": "まだ計画はありません。", diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index 2c9d93fa..091bba3d 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -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": "Пока ничего не запланировано.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts index a5ef6103..97a39a65 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -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": "暂无计划。",