Improve folder picker path input (#372)
## Summary - Adds editable path entry directly inside the folder browser dialog while keeping browse-first behavior. - Removes the multi-root workspace picker changes from the source implementation. - Refines responsive controls so mobile shows the path field first, then New Folder and Open actions together. ## Credits - Based on the work and request flow from #350. Thanks to the original requester and contributor there for the folder picker path input idea. ## Verification - npm run typecheck --workspace @neuralnomads/codenomad - npm run typecheck --workspace @codenomad/ui --------- Co-authored-by: Pascal André <pascalandr@gmail.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||
import { ArrowUpLeft, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid"
|
||||
import { ArrowRightSquare, ArrowUpLeft, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid"
|
||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
@@ -38,6 +38,7 @@ interface DirectoryBrowserDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description?: string
|
||||
initialPath?: string
|
||||
onSelect: (absolutePath: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -125,7 +126,17 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
async function initialize() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await navigateTo()
|
||||
const startPath = props.initialPath?.trim()
|
||||
if (startPath) {
|
||||
const metadata = await navigateTo(startPath)
|
||||
if (metadata) {
|
||||
return
|
||||
}
|
||||
// initialPath was rejected (e.g. no longer under an allowed root);
|
||||
// silently fall back to the default root so the dialog stays usable.
|
||||
setError(null)
|
||||
}
|
||||
await navigateTo(undefined)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -387,46 +398,47 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
<div class="panel-body directory-browser-body">
|
||||
<Show when={rootPath()}>
|
||||
<div class="directory-browser-current">
|
||||
<div class="directory-browser-current-meta">
|
||||
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput()}
|
||||
onInput={(event) => {
|
||||
setPathInput(event.currentTarget.value)
|
||||
setPathInputDirty(true)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void handlePathSubmit()
|
||||
}
|
||||
}}
|
||||
spellcheck={false}
|
||||
class="selector-input directory-browser-current-path"
|
||||
/>
|
||||
</div>
|
||||
<div class="directory-browser-current-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
|
||||
onClick={() => void handleSelectCurrent()}
|
||||
>
|
||||
{t("directoryBrowser.selectCurrent")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select"
|
||||
disabled={!canSelectCurrent() || creatingFolder()}
|
||||
onClick={() => void handleCreateFolder()}
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
{creatingFolder() ? t("directoryBrowser.creating") : t("directoryBrowser.newFolder")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput()}
|
||||
onInput={(event) => {
|
||||
setPathInput(event.currentTarget.value)
|
||||
setPathInputDirty(true)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void handlePathSubmit()
|
||||
}
|
||||
}}
|
||||
spellcheck={false}
|
||||
placeholder={t("directoryBrowser.currentFolder.inputPlaceholder")}
|
||||
aria-label={t("directoryBrowser.currentFolder.inputAriaLabel")}
|
||||
class="selector-input directory-browser-current-path"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-new-folder"
|
||||
disabled={!canSelectCurrent() || creatingFolder()}
|
||||
onClick={() => void handleCreateFolder()}
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
{creatingFolder() ? t("directoryBrowser.creating") : t("directoryBrowser.newFolder")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-open-path"
|
||||
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
|
||||
onClick={() => void handleSelectCurrent()}
|
||||
title={t("directoryBrowser.openCurrent")}
|
||||
aria-label={t("directoryBrowser.openCurrent")}
|
||||
>
|
||||
<ArrowRightSquare class="w-4 h-4" />
|
||||
<span>{t("directoryBrowser.openCurrent")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
|
||||
@@ -9,34 +9,55 @@ const log = getLogger("actions")
|
||||
|
||||
const MAX_RESULTS = 200
|
||||
|
||||
function isAbsolutePathLike(input: string): boolean {
|
||||
return input.startsWith("/") || /^[a-zA-Z]:/.test(input) || input.startsWith("\\\\")
|
||||
}
|
||||
|
||||
function normalizeEntryPath(path: string | undefined): string {
|
||||
if (!path || path === "." || path === "./") {
|
||||
return "."
|
||||
}
|
||||
// Preserve absolute paths as-is (POSIX "/...", Windows "C:\..." or UNC "\\...").
|
||||
// The server accepts absolute paths for unrestricted and multi-root listings,
|
||||
// and stripping the leading "/" would make it resolve as relative to the root.
|
||||
if (isAbsolutePathLike(path)) {
|
||||
// Only collapse duplicate slashes in POSIX absolute paths; leave Windows
|
||||
// and UNC separators untouched so the server can round-trip them.
|
||||
if (path.startsWith("/")) {
|
||||
return path.replace(/\/+/g, "/")
|
||||
}
|
||||
return path
|
||||
}
|
||||
let cleaned = path.replace(/\\/g, "/")
|
||||
if (cleaned.startsWith("./")) {
|
||||
cleaned = cleaned.replace(/^\.\/+/, "")
|
||||
}
|
||||
if (cleaned.startsWith("/")) {
|
||||
cleaned = cleaned.replace(/^\/+/, "")
|
||||
}
|
||||
cleaned = cleaned.replace(/\/+/g, "/")
|
||||
return cleaned === "" ? "." : cleaned
|
||||
}
|
||||
|
||||
function resolveAbsolutePath(root: string, relativePath: string): string {
|
||||
if (!root) {
|
||||
return relativePath
|
||||
}
|
||||
if (!relativePath || relativePath === "." || relativePath === "./") {
|
||||
return root
|
||||
}
|
||||
if (isAbsolutePathLike(relativePath)) {
|
||||
return relativePath
|
||||
}
|
||||
if (!root) {
|
||||
return relativePath
|
||||
}
|
||||
const separator = root.includes("\\") ? "\\" : "/"
|
||||
const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}`
|
||||
const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "")
|
||||
return `${trimmedRoot}${normalized}`
|
||||
}
|
||||
|
||||
function entryAbsolutePath(root: string, entry: FileSystemEntry): string {
|
||||
if (entry.absolutePath) return entry.absolutePath
|
||||
if (isAbsolutePathLike(entry.path)) return entry.path
|
||||
return resolveAbsolutePath(root, entry.path)
|
||||
}
|
||||
|
||||
|
||||
interface FileSystemBrowserDialogProps {
|
||||
open: boolean
|
||||
@@ -158,6 +179,9 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
if (!metadata) {
|
||||
return rootPath()
|
||||
}
|
||||
if (metadata.pathKind === "drives") {
|
||||
return ""
|
||||
}
|
||||
if (metadata.pathKind === "relative") {
|
||||
return resolveAbsolutePath(rootPath(), metadata.currentPath)
|
||||
}
|
||||
@@ -171,8 +195,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
}
|
||||
|
||||
function handleEntrySelect(entry: FileSystemEntry) {
|
||||
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
||||
props.onSelect(absolute)
|
||||
props.onSelect(entryAbsolutePath(rootPath(), entry))
|
||||
}
|
||||
|
||||
function handleNavigateTo(path: string) {
|
||||
@@ -197,7 +220,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
return subset
|
||||
}
|
||||
return subset.filter((entry) => {
|
||||
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
||||
const absolute = entryAbsolutePath(rootPath(), entry)
|
||||
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
|
||||
})
|
||||
})
|
||||
@@ -325,7 +348,11 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary whitespace-nowrap"
|
||||
onClick={() => props.onSelect(currentAbsolutePath())}
|
||||
disabled={!currentAbsolutePath()}
|
||||
onClick={() => {
|
||||
const abs = currentAbsolutePath()
|
||||
if (abs) props.onSelect(abs)
|
||||
}}
|
||||
>
|
||||
{t("filesystemBrowser.currentFolder.selectCurrent")}
|
||||
</button>
|
||||
@@ -408,7 +435,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
<div class="directory-browser-row-text">
|
||||
<span class="directory-browser-row-name">{entry.name || entry.path}</span>
|
||||
<span class="directory-browser-row-sub">
|
||||
{resolveAbsolutePath(rootPath(), entry.path)}
|
||||
{entryAbsolutePath(rootPath(), entry)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -400,7 +400,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
setIsFolderBrowserOpen(false)
|
||||
handleFolderSelect(path)
|
||||
}
|
||||
|
||||
|
||||
function handleRemove(path: string, e?: Event) {
|
||||
if (isLoading()) return
|
||||
e?.stopPropagation()
|
||||
@@ -961,6 +961,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
open={isFolderBrowserOpen()}
|
||||
title={t("folderSelection.dialog.title")}
|
||||
description={t("folderSelection.dialog.description")}
|
||||
initialPath={folders()[0]?.path}
|
||||
onClose={() => setIsFolderBrowserOpen(false)}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export const filesystemMessages = {
|
||||
"directoryBrowser.defaultDescription": "Browse folders under the configured workspace root.",
|
||||
"directoryBrowser.close": "Close",
|
||||
"directoryBrowser.currentFolder": "Current folder",
|
||||
"directoryBrowser.currentFolder": "Select folder or enter path",
|
||||
"directoryBrowser.currentFolder.inputAriaLabel": "Folder path",
|
||||
"directoryBrowser.currentFolder.inputPlaceholder": "Type or paste a folder path",
|
||||
"directoryBrowser.openCurrent": "Open",
|
||||
"directoryBrowser.selectCurrent": "Select Current",
|
||||
"directoryBrowser.newFolder": "New Folder",
|
||||
"directoryBrowser.creating": "Creating…",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export const filesystemMessages = {
|
||||
"directoryBrowser.defaultDescription": "Explora carpetas bajo la raíz del workspace configurado.",
|
||||
"directoryBrowser.close": "Cerrar",
|
||||
"directoryBrowser.currentFolder": "Carpeta actual",
|
||||
"directoryBrowser.currentFolder": "Seleccionar carpeta o introducir ruta",
|
||||
"directoryBrowser.currentFolder.inputAriaLabel": "Ruta de la carpeta",
|
||||
"directoryBrowser.currentFolder.inputPlaceholder": "Escribe o pega una ruta de carpeta",
|
||||
"directoryBrowser.openCurrent": "Abrir",
|
||||
"directoryBrowser.selectCurrent": "Seleccionar actual",
|
||||
"directoryBrowser.newFolder": "Nueva carpeta",
|
||||
"directoryBrowser.creating": "Creando…",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export const filesystemMessages = {
|
||||
"directoryBrowser.defaultDescription": "Parcourez les dossiers sous la racine d'espace de travail configurée.",
|
||||
"directoryBrowser.close": "Fermer",
|
||||
"directoryBrowser.currentFolder": "Dossier actuel",
|
||||
"directoryBrowser.currentFolder": "Sélectionner un dossier ou saisir un chemin",
|
||||
"directoryBrowser.currentFolder.inputAriaLabel": "Chemin du dossier",
|
||||
"directoryBrowser.currentFolder.inputPlaceholder": "Saisissez ou collez un chemin de dossier",
|
||||
"directoryBrowser.openCurrent": "Ouvrir",
|
||||
"directoryBrowser.selectCurrent": "Sélectionner le dossier actuel",
|
||||
"directoryBrowser.newFolder": "Nouveau dossier",
|
||||
"directoryBrowser.creating": "Création…",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export const filesystemMessages = {
|
||||
"directoryBrowser.defaultDescription": "עיון בתיקיות תחת שורש סביבת העבודה המוגדר.",
|
||||
"directoryBrowser.close": "סגור",
|
||||
"directoryBrowser.currentFolder": "תיקייה נוכחית",
|
||||
"directoryBrowser.currentFolder": "בחר תיקייה או הזן נתיב",
|
||||
"directoryBrowser.currentFolder.inputAriaLabel": "נתיב התיקייה",
|
||||
"directoryBrowser.currentFolder.inputPlaceholder": "הקלד או הדבק נתיב תיקייה",
|
||||
"directoryBrowser.openCurrent": "פתח",
|
||||
"directoryBrowser.selectCurrent": "בחר נוכחית",
|
||||
"directoryBrowser.newFolder": "תיקייה חדשה",
|
||||
"directoryBrowser.creating": "יוצר…",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export const filesystemMessages = {
|
||||
"directoryBrowser.defaultDescription": "設定された workspace ルート配下のフォルダを参照します。",
|
||||
"directoryBrowser.close": "閉じる",
|
||||
"directoryBrowser.currentFolder": "現在のフォルダ",
|
||||
"directoryBrowser.currentFolder": "フォルダを選択またはパスを入力",
|
||||
"directoryBrowser.currentFolder.inputAriaLabel": "フォルダのパス",
|
||||
"directoryBrowser.currentFolder.inputPlaceholder": "フォルダパスを入力または貼り付け",
|
||||
"directoryBrowser.openCurrent": "開く",
|
||||
"directoryBrowser.selectCurrent": "現在のフォルダを選択",
|
||||
"directoryBrowser.newFolder": "新しいフォルダ",
|
||||
"directoryBrowser.creating": "作成中…",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export const filesystemMessages = {
|
||||
"directoryBrowser.defaultDescription": "Просматривайте папки в пределах настроенного корня рабочего пространства.",
|
||||
"directoryBrowser.close": "Закрыть",
|
||||
"directoryBrowser.currentFolder": "Текущая папка",
|
||||
"directoryBrowser.currentFolder": "Выберите папку или введите путь",
|
||||
"directoryBrowser.currentFolder.inputAriaLabel": "Путь к папке",
|
||||
"directoryBrowser.currentFolder.inputPlaceholder": "Введите или вставьте путь к папке",
|
||||
"directoryBrowser.openCurrent": "Открыть",
|
||||
"directoryBrowser.selectCurrent": "Выбрать текущую",
|
||||
"directoryBrowser.newFolder": "Новая папка",
|
||||
"directoryBrowser.creating": "Создание…",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export const filesystemMessages = {
|
||||
"directoryBrowser.defaultDescription": "浏览已配置的工作区根目录下的文件夹。",
|
||||
"directoryBrowser.close": "关闭",
|
||||
"directoryBrowser.currentFolder": "当前文件夹",
|
||||
"directoryBrowser.currentFolder": "选择文件夹或输入路径",
|
||||
"directoryBrowser.currentFolder.inputAriaLabel": "文件夹路径",
|
||||
"directoryBrowser.currentFolder.inputPlaceholder": "输入或粘贴文件夹路径",
|
||||
"directoryBrowser.openCurrent": "打开",
|
||||
"directoryBrowser.selectCurrent": "选择当前",
|
||||
"directoryBrowser.newFolder": "新建文件夹",
|
||||
"directoryBrowser.creating": "正在创建…",
|
||||
|
||||
@@ -51,20 +51,18 @@
|
||||
}
|
||||
|
||||
.directory-browser-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"label new-folder"
|
||||
"path open";
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.directory-browser-current-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
|
||||
.directory-browser-current-label {
|
||||
grid-area: label;
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -72,21 +70,60 @@
|
||||
}
|
||||
|
||||
.directory-browser-current-path {
|
||||
grid-area: path;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.directory-browser-current-select {
|
||||
.directory-browser-new-folder {
|
||||
grid-area: new-folder;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.directory-browser-current-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
.directory-browser-open-path {
|
||||
grid-area: open;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
gap: var(--space-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.directory-browser-current {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-areas:
|
||||
"label label"
|
||||
"path path"
|
||||
"new-folder open";
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.directory-browser-new-folder,
|
||||
.directory-browser-open-path {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.directory-browser-open-path {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.directory-browser-current {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"label"
|
||||
"path"
|
||||
"new-folder"
|
||||
"open";
|
||||
}
|
||||
|
||||
.directory-browser-new-folder,
|
||||
.directory-browser-open-path {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.directory-browser-close {
|
||||
|
||||
Reference in New Issue
Block a user