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:
Shantur Rathore
2026-04-26 14:31:01 +01:00
committed by GitHub
parent e17f346581
commit 2a25abce03
15 changed files with 1010 additions and 84 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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…",

View File

@@ -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…",

View File

@@ -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…",

View File

@@ -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": "יוצר…",

View File

@@ -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": "作成中…",

View File

@@ -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": "Создание…",

View File

@@ -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": "正在创建…",

View File

@@ -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 {