From c8a811fc7a94fb92b286a9ed6cff3d8c39d2672e Mon Sep 17 00:00:00 2001 From: "Daniel J. Lewis" Date: Sat, 28 Feb 2026 14:08:14 -0500 Subject: [PATCH 1/2] enhanced subfolder searching --- README.md | 1 + docs/ARCHITECTURE.md | 1 + docs/REFERENCE.md | 2 +- lib/routes/files.js | 185 +++++++- public/css/components.css | 147 ++++++ public/index.html | 21 +- public/js/app.js | 22 + public/js/ui.js | 11 + public/js/ui/directory-browser.js | 730 +++++++++++++++++++++++++++--- test/files-routes.test.js | 62 +++ 10 files changed, 1121 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 502e759..b4f361d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A mobile-first PWA for AI-assisted development. Chat with [Claude Code](https:// ### Project Integration - **File browser** - Browse and view files in the conversation's working directory +- **Directory picker** - New-chat cwd chooser with breadcrumbs, fuzzy filtering, deep recursive search, favorites, and keyboard navigation - **File preview** - View text/code, Markdown, JSON, images, CSV/TSV, Parquet, Jupyter notebooks, and geospatial files inline - **Geospatial map viewer** - Interactive GeoJSON/JSONL map mode with Map/Raw toggle, basemap switcher, thematic styling, feature hover metadata, and fit-to-bounds controls - **Git integration** - Full git workflow: status, stage/unstage, commit, push/pull, branches, stash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 284c0be..1defce0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -236,6 +236,7 @@ Conversations default to sandboxed mode for safety. Sandbox configuration: | Method | Endpoint | Description | |--------|----------|-------------| | `GET` | `/api/browse` | Directory listing (cwd picker) | +| `GET` | `/api/browse/search` | Recursive directory search for cwd picker fuzzy find | | `GET` | `/api/files` | General file browser | | `GET` | `/api/files/content` | Get structured file content (standalone cwd) | | `GET` | `/api/files/download` | Download file | diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 70aa205..7a49916 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -19,7 +19,7 @@ lib/ index.js # Route setup conversations.js # CRUD, export, fork, compress, search git.js # Git operations - files.js # File browser + files.js # File browser + cwd picker (browse + recursive search) memory.js # Memory system capabilities.js # Model/CLI capabilities preview.js # Live web preview server controls diff --git a/lib/routes/files.js b/lib/routes/files.js index 760d0f4..e52b440 100644 --- a/lib/routes/files.js +++ b/lib/routes/files.js @@ -69,11 +69,159 @@ const BINARY_EXTS = new Set([ const MAX_FILE_SIZE = 500 * 1024; // 500KB const MAX_GEO_FILE_SIZE = 20 * 1024 * 1024; // 20MB const MAX_GEO_RAW_FILE_SIZE = MAX_FILE_SIZE; // Keep raw text preview conservative on large geo files. +const BROWSE_SEARCH_DEFAULT_LIMIT = 50; +const BROWSE_SEARCH_MAX_LIMIT = 200; +const BROWSE_SEARCH_DEFAULT_DEPTH = 4; +const BROWSE_SEARCH_MAX_DEPTH = 8; +const BROWSE_SEARCH_MAX_SCANNED_DIRS = 5000; +const BROWSE_SEARCH_SKIP_NAMES = new Set([ + 'node_modules', + '.git', + '.svn', + '.hg', + '.idea', + '.vscode', + '.venv', + 'venv', + '__pycache__', + '.mypy_cache', + '.pytest_cache', + '.cache', +]); function getPreviewSizeLimitForExtension(ext) { return LARGE_GEO_PREVIEW_EXTS.has(ext) ? MAX_GEO_FILE_SIZE : MAX_FILE_SIZE; } +function clampInteger(value, fallback, min, max) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +function resolveBrowsePathInput(rawPath) { + const value = String(rawPath || '').trim(); + if (!value) return process.env.HOME || '/'; + if (value === '~') return process.env.HOME || '/'; + if (value.startsWith('~/') && process.env.HOME) { + return path.resolve(process.env.HOME, value.slice(2)); + } + return path.resolve(value); +} + +function buildDirectorySearchScore(name, relPath, queryLower, tokens) { + const safeRel = String(relPath || '').replace(/\\/g, '/'); + const nameLower = String(name || '').toLowerCase(); + const relLower = safeRel.toLowerCase(); + + if (!relLower) return null; + if (tokens.length > 0 && !tokens.every((token) => relLower.includes(token))) return null; + + let score = 0; + if (nameLower === queryLower) score += 120; + if (nameLower.startsWith(queryLower)) score += 85; + if (relLower.startsWith(queryLower)) score += 60; + if (nameLower.includes(queryLower)) score += 40; + if (relLower.includes(queryLower)) score += 25; + + let ordered = true; + let cursor = 0; + for (const token of tokens) { + const next = relLower.indexOf(token, cursor); + if (next === -1) { + ordered = false; + break; + } + cursor = next + token.length; + } + if (ordered) score += 12; + + score -= safeRel.length / 200; + return score; +} + +async function searchDirectoriesRecursive(basePath, query, { limit, depth }) { + const root = path.resolve(basePath); + const queryLower = String(query || '').trim().toLowerCase(); + const tokens = queryLower.split(/\s+/).filter(Boolean); + const queue = [{ absPath: root, relPath: '', depth: 0 }]; + const visited = new Set([root]); + const results = []; + let queueIndex = 0; + let scannedDirs = 0; + let truncated = false; + + while (queueIndex < queue.length) { + const current = queue[queueIndex++]; + scannedDirs++; + if (scannedDirs > BROWSE_SEARCH_MAX_SCANNED_DIRS) { + truncated = true; + break; + } + + let entries; + try { + entries = await fsp.readdir(current.absPath, { withFileTypes: true }); + } catch (err) { + if (err?.code === 'EPERM' || err?.code === 'EACCES') { + continue; + } + throw err; + } + + entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.')) continue; + if (BROWSE_SEARCH_SKIP_NAMES.has(entry.name)) continue; + + const childAbsPath = path.join(current.absPath, entry.name); + const childRelPath = current.relPath ? `${current.relPath}/${entry.name}` : entry.name; + const normalizedRelPath = childRelPath.replace(/\\/g, '/'); + const score = buildDirectorySearchScore(entry.name, normalizedRelPath, queryLower, tokens); + if (score !== null) { + results.push({ + path: childAbsPath, + relPath: normalizedRelPath, + name: entry.name, + score, + }); + } + + if (current.depth < depth && !visited.has(childAbsPath)) { + visited.add(childAbsPath); + queue.push({ + absPath: childAbsPath, + relPath: normalizedRelPath, + depth: current.depth + 1, + }); + } + + if (results.length >= limit) { + truncated = true; + break; + } + } + + if (results.length >= limit) break; + } + + results.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.relPath.localeCompare(b.relPath, undefined, { sensitivity: 'base' }); + }); + + return { + results: results.slice(0, limit).map(({ path: itemPath, relPath, name }) => ({ + path: itemPath, + relPath, + name, + })), + truncated, + }; +} + /** * Parse CSV/TSV file with row limit * Returns { columns, rows, totalRows, truncated } @@ -446,7 +594,7 @@ function setupFileRoutes(app) { // Browse directories (for cwd picker) app.get('/api/browse', async (req, res) => { const target = req.query.path || process.env.HOME; - const resolved = path.resolve(target); + const resolved = resolveBrowsePathInput(target); try { const entries = await fsp.readdir(resolved, { withFileTypes: true }); const dirs = entries @@ -459,6 +607,41 @@ function setupFileRoutes(app) { } }); + // Recursive directory search (for cwd picker fuzzy find) + app.get('/api/browse/search', async (req, res) => { + const baseInput = req.query.base || req.query.path; + const query = String(req.query.q || '').trim(); + if (!baseInput) return res.status(400).json({ error: 'base required' }); + if (!query) return res.status(400).json({ error: 'q required' }); + + const base = resolveBrowsePathInput(baseInput); + const limit = clampInteger(req.query.limit, BROWSE_SEARCH_DEFAULT_LIMIT, 1, BROWSE_SEARCH_MAX_LIMIT); + const depth = clampInteger(req.query.depth, BROWSE_SEARCH_DEFAULT_DEPTH, 1, BROWSE_SEARCH_MAX_DEPTH); + + let baseStat; + try { + baseStat = await fsp.stat(base); + } catch (err) { + if (err?.code === 'EPERM' || err?.code === 'EACCES') { + return res.status(403).json({ error: 'Permission denied', base }); + } + return res.status(404).json({ error: 'Base directory not found', base }); + } + if (!baseStat.isDirectory()) { + return res.status(400).json({ error: 'base must be a directory', base }); + } + + try { + const { results, truncated } = await searchDirectoriesRecursive(base, query, { limit, depth }); + res.json({ base, q: query, limit, depth, results, truncated }); + } catch (err) { + if (err?.code === 'EPERM' || err?.code === 'EACCES') { + return res.status(403).json({ error: 'Permission denied', base }); + } + res.status(500).json({ error: err.message || 'Directory search failed', base }); + } + }); + // General file browser app.get('/api/files', async (req, res) => { const targetPath = req.query.path || process.env.HOME; diff --git a/public/css/components.css b/public/css/components.css index 646e392..20d9f76 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -497,6 +497,11 @@ min-width: 0; } +.cwd-input-row input.input-error { + border-color: var(--danger); + box-shadow: 0 0 0 2px var(--danger-alpha-20, var(--danger-alpha-15)); +} + /* Recent directories */ .recent-dirs { margin-top: 8px; @@ -578,6 +583,10 @@ color: var(--accent-light); } +.dir-browser-header .btn-icon.active { + color: var(--accent); +} + .dir-current-path { font-size: 12px; color: var(--text-secondary); @@ -590,10 +599,114 @@ flex: 1; } +.dir-browser-search-row { + display: flex; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: var(--bg); +} + +.dir-filter-input { + flex: 1; + min-width: 0; + padding: 8px 10px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 13px; + font-family: inherit; + outline: none; +} + +.dir-filter-input:focus { + border-color: var(--accent); +} + +.dir-breadcrumbs { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.dir-breadcrumb-btn { + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + padding: 2px 4px; + border-radius: 6px; + cursor: pointer; +} + +.dir-breadcrumb-btn:hover { + background: var(--bg-secondary); + color: var(--text); +} + +.dir-breadcrumb-sep { + font-size: 11px; + color: var(--text-secondary); +} + +.dir-section { + padding: 8px 10px 0; +} + +.dir-section.hidden { + display: none; +} + +.dir-section-title { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 6px; +} + +.dir-chip-list { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.dir-chip { + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dir-chip:hover { + border-color: var(--accent-alpha-35); + background: var(--bg-secondary); +} + .dir-list { max-height: 200px; overflow-y: auto; -webkit-overflow-scrolling: touch; + margin-top: 8px; +} + +.dir-list-compact { + max-height: 120px; + margin-top: 0; + border: 1px solid var(--border); + border-radius: 8px; } .dir-item { @@ -605,12 +718,20 @@ color: var(--text); cursor: pointer; border-bottom: 1px solid var(--border); + width: 100%; + border-left: none; + border-right: none; + border-top: none; + background: transparent; + text-align: left; + font-family: inherit; } .dir-item:last-child { border-bottom: none; } +.dir-item:hover, .dir-item:active { background: var(--surface); } @@ -627,6 +748,16 @@ min-width: 0; } +.dir-item.active { + background: var(--accent-alpha-15); + outline: 1px solid var(--accent-alpha-40); + outline-offset: -1px; +} + +.dir-item-secondary { + font-size: 13px; +} + .dir-empty { padding: 16px; text-align: center; @@ -634,6 +765,22 @@ color: var(--text-secondary); } +.dir-search-note { + padding-top: 8px; + padding-bottom: 8px; +} + +.dir-status { + min-height: 22px; + padding: 4px 10px 8px; + font-size: 12px; + color: var(--text-secondary); +} + +.dir-status.error { + color: var(--danger); +} + .dir-browser-actions { display: flex; gap: 8px; diff --git a/public/index.html b/public/index.html index 328b94e..eef58df 100644 --- a/public/index.html +++ b/public/index.html @@ -538,8 +538,27 @@

New Conversation

+
-
+
+ + +
+
+ + + +
+
diff --git a/public/js/app.js b/public/js/app.js index 86a92cd..54390ba 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -71,7 +71,18 @@ const browseBtn = document.getElementById('browse-btn'); const dirBrowser = document.getElementById('dir-browser'); const dirUpBtn = document.getElementById('dir-up-btn'); const dirCurrentPath = document.getElementById('dir-current-path'); +const dirFavoriteToggle = document.getElementById('dir-favorite-toggle'); +const dirFilterInput = document.getElementById('dir-filter-input'); +const dirDeepSearchBtn = document.getElementById('dir-deep-search-btn'); +const dirBreadcrumbs = document.getElementById('dir-breadcrumbs'); +const dirFavorites = document.getElementById('dir-favorites'); +const dirFavoritesList = document.getElementById('dir-favorites-list'); +const dirRecents = document.getElementById('dir-recents'); +const dirRecentsList = document.getElementById('dir-recents-list'); +const dirSearchResults = document.getElementById('dir-search-results'); +const dirSearchResultsList = document.getElementById('dir-search-results-list'); const dirList = document.getElementById('dir-list'); +const dirStatus = document.getElementById('dir-status'); const dirNewBtn = document.getElementById('dir-new-btn'); const dirSelectBtn = document.getElementById('dir-select-btn'); const micBtn = document.getElementById('mic-btn'); @@ -301,7 +312,18 @@ initUI({ dirBrowser, dirUpBtn, dirCurrentPath, + dirFavoriteToggle, + dirFilterInput, + dirDeepSearchBtn, + dirBreadcrumbs, + dirFavorites, + dirFavoritesList, + dirRecents, + dirRecentsList, + dirSearchResults, + dirSearchResultsList, dirList, + dirStatus, dirNewBtn, dirSelectBtn, micBtn, diff --git a/public/js/ui.js b/public/js/ui.js index 3308d28..8e8d091 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -516,7 +516,18 @@ export function initUI(elements) { dirBrowser: elements.dirBrowser, dirUpBtn: elements.dirUpBtn, dirCurrentPath: elements.dirCurrentPath, + dirFavoriteToggle: elements.dirFavoriteToggle, + dirFilterInput: elements.dirFilterInput, + dirDeepSearchBtn: elements.dirDeepSearchBtn, + dirBreadcrumbs: elements.dirBreadcrumbs, + dirFavorites: elements.dirFavorites, + dirFavoritesList: elements.dirFavoritesList, + dirRecents: elements.dirRecents, + dirRecentsList: elements.dirRecentsList, + dirSearchResults: elements.dirSearchResults, + dirSearchResultsList: elements.dirSearchResultsList, dirList: elements.dirList, + dirStatus: elements.dirStatus, dirNewBtn: elements.dirNewBtn, dirSelectBtn: elements.dirSelectBtn, convCwdInput: elements.convCwdInput, diff --git a/public/js/ui/directory-browser.js b/public/js/ui/directory-browser.js index a413953..9acd08f 100644 --- a/public/js/ui/directory-browser.js +++ b/public/js/ui/directory-browser.js @@ -1,103 +1,717 @@ // --- Directory browsing (for new conversation modal) --- import { escapeHtml } from '../markdown.js'; -import { showDialog, apiFetch } from '../utils.js'; +import { showDialog } from '../utils.js'; import * as state from '../state.js'; +const FAVORITES_STORAGE_KEY = 'directoryFavorites:v1'; +const RECENTS_STORAGE_KEY = 'directoryRecentPaths:v1'; +const FAVORITES_MAX = 20; +const RECENTS_MAX = 10; +const DEEP_SEARCH_DEBOUNCE_MS = 200; +const DEEP_SEARCH_MIN_QUERY_LENGTH = 2; + // DOM elements (set by init) let browseBtn = null; let dirBrowser = null; let dirUpBtn = null; let dirCurrentPath = null; +let dirFavoriteToggle = null; +let dirFilterInput = null; +let dirDeepSearchBtn = null; +let dirBreadcrumbs = null; +let dirFavorites = null; +let dirFavoritesList = null; +let dirRecents = null; +let dirRecentsList = null; +let dirSearchResults = null; +let dirSearchResultsList = null; let dirList = null; +let dirStatus = null; let dirNewBtn = null; let dirSelectBtn = null; let convCwdInput = null; -export function initDirectoryBrowser(elements) { - browseBtn = elements.browseBtn; - dirBrowser = elements.dirBrowser; - dirUpBtn = elements.dirUpBtn; - dirCurrentPath = elements.dirCurrentPath; - dirList = elements.dirList; - dirNewBtn = elements.dirNewBtn; - dirSelectBtn = elements.dirSelectBtn; - convCwdInput = elements.convCwdInput; +let currentDirs = []; +let filteredDirs = []; +let activeDirIndex = -1; +let deepSearchResults = []; +let deepSearchTruncated = false; +let deepSearchDebounceTimer = null; +let deepSearchRequestId = 0; +let deepSearchAbortController = null; + +function normalizePathValue(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + const compact = raw.replace(/\/+$/, ''); + return compact || '/'; } -// --- Directory browser --- -export async function browseTo(dirPath) { - const qs = dirPath ? `?path=${encodeURIComponent(dirPath)}` : ''; - const res = await apiFetch(`/api/browse${qs}`, { silent: true }); - if (!res) { - dirList.innerHTML = `
Failed to browse
`; +function joinPath(basePath, childName) { + const base = normalizePathValue(basePath); + if (!base || base === '/') return `/${childName}`; + return `${base}/${childName}`; +} + +function getParentPath(currentPath) { + const normalized = normalizePathValue(currentPath); + if (!normalized || normalized === '/') return '/'; + const idx = normalized.lastIndexOf('/'); + if (idx <= 0) return '/'; + return normalized.slice(0, idx); +} + +function clampArray(values, limit) { + return values.slice(0, limit); +} + +function readStoredPathList(key) { + try { + const value = localStorage.getItem(key); + if (!value) return []; + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) return []; + return parsed + .map((item) => normalizePathValue(item)) + .filter(Boolean); + } catch { + return []; + } +} + +function writeStoredPathList(key, values) { + try { + localStorage.setItem(key, JSON.stringify(values)); + } catch { + // Ignore storage write failures (e.g. private mode quota restrictions). + } +} + +function getFavoritePaths() { + return readStoredPathList(FAVORITES_STORAGE_KEY); +} + +function setFavoritePaths(paths) { + writeStoredPathList(FAVORITES_STORAGE_KEY, clampArray(paths, FAVORITES_MAX)); +} + +function getRecentPaths() { + return readStoredPathList(RECENTS_STORAGE_KEY); +} + +function setRecentPaths(paths) { + writeStoredPathList(RECENTS_STORAGE_KEY, clampArray(paths, RECENTS_MAX)); +} + +function upsertPathAtFront(values, item, maxSize) { + const normalized = normalizePathValue(item); + if (!normalized) return values; + const deduped = values.filter((entry) => normalizePathValue(entry) !== normalized); + return clampArray([normalized, ...deduped], maxSize); +} + +function scoreLocalDirectory(name, queryLower, queryTokens) { + const target = String(name || '').toLowerCase(); + if (!target) return null; + if (queryTokens.length > 0 && !queryTokens.every((token) => target.includes(token))) return null; + + let score = 0; + if (target === queryLower) score += 100; + if (target.startsWith(queryLower)) score += 70; + if (target.includes(queryLower)) score += 30; + + let ordered = true; + let cursor = 0; + for (const token of queryTokens) { + const idx = target.indexOf(token, cursor); + if (idx === -1) { + ordered = false; + break; + } + cursor = idx + token.length; + } + if (ordered) score += 8; + + score -= target.length / 200; + return score; +} + +function clearDeepSearchState() { + if (deepSearchDebounceTimer) { + clearTimeout(deepSearchDebounceTimer); + deepSearchDebounceTimer = null; + } + if (deepSearchAbortController) { + deepSearchAbortController.abort(); + deepSearchAbortController = null; + } + deepSearchResults = []; + deepSearchTruncated = false; + renderDeepSearchResults(); +} + +function setStatusMessage(message, { error = false } = {}) { + if (!dirStatus) return; + dirStatus.textContent = message || ''; + dirStatus.classList.toggle('error', !!error); +} + +function setCwdInputValidity(valid) { + if (!convCwdInput) return; + convCwdInput.classList.toggle('input-error', !valid); + if (valid) { + convCwdInput.removeAttribute('aria-invalid'); + } else { + convCwdInput.setAttribute('aria-invalid', 'true'); + } +} + +function buildBreadcrumbSegments(pathValue) { + const normalized = normalizePathValue(pathValue); + if (!normalized || normalized === '/') { + return [{ label: '/', path: '/' }]; + } + + if (normalized.startsWith('/')) { + const parts = normalized.slice(1).split('/').filter(Boolean); + const segments = [{ label: '/', path: '/' }]; + let current = '/'; + for (const part of parts) { + current = current === '/' ? `/${part}` : `${current}/${part}`; + segments.push({ label: part, path: current }); + } + return segments; + } + + // Fallback for non-posix paths. + return [{ label: normalized, path: normalized }]; +} + +function renderBreadcrumbs() { + if (!dirBreadcrumbs) return; + const currentPath = state.getCurrentBrowsePath() || convCwdInput?.value || ''; + const segments = buildBreadcrumbSegments(currentPath); + dirBreadcrumbs.innerHTML = segments.map((segment, index) => { + const separator = index < segments.length - 1 + ? '/' + : ''; + return ( + `' + + separator + ); + }).join(''); +} + +function renderFavoriteToggle() { + if (!dirFavoriteToggle) return; + const currentPath = normalizePathValue(state.getCurrentBrowsePath()); + const favorites = getFavoritePaths(); + const isFavorite = !!currentPath && favorites.includes(currentPath); + dirFavoriteToggle.innerHTML = isFavorite ? '★' : '☆'; + dirFavoriteToggle.setAttribute('aria-label', isFavorite ? 'Remove from favorites' : 'Add to favorites'); + dirFavoriteToggle.title = isFavorite ? 'Remove from favorites' : 'Add to favorites'; + dirFavoriteToggle.classList.toggle('active', isFavorite); +} + +function renderPathChipSection(sectionEl, listEl, paths, sectionClass) { + if (!sectionEl || !listEl) return; + if (!paths.length) { + sectionEl.classList.add('hidden'); + listEl.innerHTML = ''; return; } - const data = await res.json(); - if (data.error) { - dirList.innerHTML = `
${escapeHtml(data.error)}
`; + + sectionEl.classList.remove('hidden'); + listEl.innerHTML = paths.map((entryPath) => { + const label = entryPath.split('/').filter(Boolean).pop() || entryPath; + return ( + `' + ); + }).join(''); +} + +function renderFavorites() { + renderPathChipSection(dirFavorites, dirFavoritesList, getFavoritePaths(), 'dir-favorite-chip'); +} + +function renderRecents() { + const favorites = new Set(getFavoritePaths()); + const recents = getRecentPaths().filter((entry) => !favorites.has(entry)); + renderPathChipSection(dirRecents, dirRecentsList, recents, 'dir-recent-chip'); +} + +function updateActiveDirItem() { + if (!dirList) return; + const items = dirList.querySelectorAll('.dir-item'); + if (!items.length) { + activeDirIndex = -1; + dirList.removeAttribute('aria-activedescendant'); return; } - state.setCurrentBrowsePath(data.path); - dirCurrentPath.textContent = data.path; - convCwdInput.value = data.path; - if (data.dirs.length === 0) { - dirList.innerHTML = '
No subdirectories
'; + if (activeDirIndex < 0 || activeDirIndex >= items.length) activeDirIndex = 0; + + items.forEach((item, index) => { + const isActive = index === activeDirIndex; + item.classList.toggle('active', isActive); + item.setAttribute('aria-selected', isActive ? 'true' : 'false'); + if (isActive) { + dirList.setAttribute('aria-activedescendant', item.id); + } + }); +} + +function renderLocalDirectoryList() { + if (!dirList) return; + + const query = (dirFilterInput?.value || '').trim().toLowerCase(); + if (!query) { + filteredDirs = [...currentDirs]; } else { - dirList.innerHTML = data.dirs.map(d => - `
` + - `📁` + - `${escapeHtml(d)}` + - `
` - ).join(''); - dirList.querySelectorAll('.dir-item').forEach(item => { - item.addEventListener('click', () => { - browseTo(state.getCurrentBrowsePath() + '/' + item.dataset.name); + const tokens = query.split(/\s+/).filter(Boolean); + const ranked = currentDirs + .map((name) => ({ name, score: scoreLocalDirectory(name, query, tokens) })) + .filter((item) => item.score !== null) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); }); - }); + filteredDirs = ranked.map((item) => item.name); + } + + if (filteredDirs.length === 0) { + dirList.innerHTML = '
No folders in this location
'; + activeDirIndex = -1; + dirList.removeAttribute('aria-activedescendant'); + } else { + dirList.innerHTML = filteredDirs.map((name, index) => { + const fullPath = joinPath(state.getCurrentBrowsePath(), name); + return ( + `' + ); + }).join(''); + activeDirIndex = 0; + updateActiveDirItem(); + } +} + +function renderDeepSearchResults() { + if (!dirSearchResults || !dirSearchResultsList) return; + + if (!deepSearchResults.length) { + dirSearchResults.classList.add('hidden'); + dirSearchResultsList.innerHTML = ''; + return; + } + + dirSearchResults.classList.remove('hidden'); + dirSearchResultsList.innerHTML = deepSearchResults.map((entry, index) => ( + `' + )).join(''); + + if (deepSearchTruncated) { + dirSearchResultsList.insertAdjacentHTML( + 'beforeend', + '
Showing top matches. Narrow query for more precise results.
' + ); + } +} + +async function fetchBrowseData(dirPath) { + const qs = dirPath ? `?path=${encodeURIComponent(dirPath)}` : ''; + try { + const response = await fetch(`/api/browse${qs}`); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.error) { + return { ok: false, error: payload.error || `Request failed (${response.status})`, path: payload.path }; + } + return { ok: true, data: payload }; + } catch (err) { + return { ok: false, error: err.message || 'Failed to browse directory' }; + } +} + +async function applyBrowseData(payload, { preserveFilter = false } = {}) { + state.setCurrentBrowsePath(payload.path); + currentDirs = Array.isArray(payload.dirs) ? [...payload.dirs] : []; + currentDirs.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + + if (dirCurrentPath) dirCurrentPath.textContent = payload.path; + if (convCwdInput) convCwdInput.value = payload.path; + setCwdInputValidity(true); + if (!preserveFilter && dirFilterInput) dirFilterInput.value = ''; + + renderBreadcrumbs(); + renderFavoriteToggle(); + renderFavorites(); + renderRecents(); + renderLocalDirectoryList(); + clearDeepSearchState(); + setStatusMessage(currentDirs.length ? `${currentDirs.length} folder${currentDirs.length === 1 ? '' : 's'}` : 'No subdirectories'); +} + +// --- Directory browser --- +export async function browseTo(dirPath, options = {}) { + const result = await fetchBrowseData(dirPath); + if (!result.ok) { + setStatusMessage(result.error || 'Failed to browse directory', { error: true }); + return false; + } + await applyBrowseData(result.data, options); + return true; +} + +async function browseToInputPath() { + const raw = convCwdInput?.value?.trim() || ''; + if (!raw) return false; + + const ok = await browseTo(raw, { preserveFilter: false }); + if (!ok) { + setCwdInputValidity(false); + setStatusMessage('Directory not found or not accessible', { error: true }); + return false; } + setCwdInputValidity(true); + return true; +} + +function addRecentPath(entryPath) { + const current = getRecentPaths(); + const next = upsertPathAtFront(current, entryPath, RECENTS_MAX); + setRecentPaths(next); +} + +function toggleCurrentFavorite() { + const currentPath = normalizePathValue(state.getCurrentBrowsePath()); + if (!currentPath) return; + + const favorites = getFavoritePaths(); + const exists = favorites.includes(currentPath); + const next = exists + ? favorites.filter((entry) => entry !== currentPath) + : upsertPathAtFront(favorites, currentPath, FAVORITES_MAX); + setFavoritePaths(next); + renderFavoriteToggle(); + renderFavorites(); + renderRecents(); +} + +function confirmSelection() { + const currentPath = normalizePathValue(state.getCurrentBrowsePath()) || normalizePathValue(convCwdInput?.value); + if (!currentPath || !convCwdInput) return; + convCwdInput.value = currentPath; + addRecentPath(currentPath); + renderRecents(); + dirBrowser.classList.add('hidden'); +} + +async function runDeepSearch(force = false) { + const base = normalizePathValue(state.getCurrentBrowsePath() || convCwdInput?.value); + const query = (dirFilterInput?.value || '').trim(); + if (!base || query.length < DEEP_SEARCH_MIN_QUERY_LENGTH) { + clearDeepSearchState(); + return; + } + if (!force && filteredDirs.length > 0) { + deepSearchResults = []; + deepSearchTruncated = false; + renderDeepSearchResults(); + return; + } + + if (deepSearchAbortController) deepSearchAbortController.abort(); + const AbortControllerCtor = globalThis.AbortController; + if (!AbortControllerCtor) return; + const controller = new AbortControllerCtor(); + deepSearchAbortController = controller; + const requestId = ++deepSearchRequestId; + setStatusMessage('Searching nested folders...'); + + const qs = new URLSearchParams({ + base, + q: query, + limit: '50', + depth: '4', + }); + + try { + const response = await fetch(`/api/browse/search?${qs.toString()}`, { signal: controller.signal }); + const payload = await response.json().catch(() => ({})); + if (requestId !== deepSearchRequestId) return; + if (!response.ok || payload.error) { + deepSearchResults = []; + deepSearchTruncated = false; + renderDeepSearchResults(); + setStatusMessage(payload.error || `Search failed (${response.status})`, { error: true }); + return; + } + + deepSearchResults = Array.isArray(payload.results) ? payload.results : []; + deepSearchTruncated = !!payload.truncated; + renderDeepSearchResults(); + if (deepSearchResults.length > 0) { + setStatusMessage(`Found ${deepSearchResults.length} nested match${deepSearchResults.length === 1 ? '' : 'es'}`); + } else { + setStatusMessage('No nested matches found'); + } + } catch (err) { + if (err?.name === 'AbortError') return; + deepSearchResults = []; + deepSearchTruncated = false; + renderDeepSearchResults(); + setStatusMessage(err.message || 'Directory search failed', { error: true }); + } +} + +function queueDeepSearch(force = false) { + if (deepSearchDebounceTimer) clearTimeout(deepSearchDebounceTimer); + deepSearchDebounceTimer = setTimeout(() => { + deepSearchDebounceTimer = null; + void runDeepSearch(force); + }, DEEP_SEARCH_DEBOUNCE_MS); +} + +function setActiveIndex(nextIndex) { + if (filteredDirs.length === 0) { + activeDirIndex = -1; + updateActiveDirItem(); + return; + } + activeDirIndex = Math.max(0, Math.min(filteredDirs.length - 1, nextIndex)); + updateActiveDirItem(); +} + +function moveActiveIndex(delta) { + if (filteredDirs.length === 0) return; + const start = activeDirIndex < 0 ? 0 : activeDirIndex; + setActiveIndex(start + delta); +} + +async function openActiveDirectory() { + if (filteredDirs.length === 0) return; + const index = activeDirIndex < 0 ? 0 : activeDirIndex; + const name = filteredDirs[index]; + if (!name) return; + await browseTo(joinPath(state.getCurrentBrowsePath(), name)); +} + +async function openDirectoryBrowser() { + dirBrowser.classList.remove('hidden'); + const seedPath = convCwdInput.value.trim() || state.getCurrentBrowsePath() || ''; + const ok = await browseTo(seedPath); + if (!ok) { + await browseTo(''); + } + dirFilterInput?.focus(); +} + +function renderSectionPathFromEvent(target, selector) { + const button = target.closest(selector); + if (!button) return null; + return button.dataset.path || null; +} + +function handleDirectoryBrowserKeyboard(e) { + if (dirBrowser.classList.contains('hidden')) return; + + if (e.key === 'Escape') { + e.preventDefault(); + dirBrowser.classList.add('hidden'); + return; + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + confirmSelection(); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + moveActiveIndex(1); + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + moveActiveIndex(-1); + return; + } + + if (e.key === 'Enter') { + e.preventDefault(); + void openActiveDirectory(); + return; + } + + if (e.key === 'Backspace' && document.activeElement === dirFilterInput && !dirFilterInput.value.trim()) { + e.preventDefault(); + void browseTo(getParentPath(state.getCurrentBrowsePath())); + } +} + +function attachSectionPathHandler(containerEl, selector) { + if (!containerEl) return; + containerEl.addEventListener('click', (event) => { + const targetPath = renderSectionPathFromEvent(event.target, selector); + if (!targetPath) return; + void browseTo(targetPath); + }); +} + +export function initDirectoryBrowser(elements) { + browseBtn = elements.browseBtn; + dirBrowser = elements.dirBrowser; + dirUpBtn = elements.dirUpBtn; + dirCurrentPath = elements.dirCurrentPath; + dirFavoriteToggle = elements.dirFavoriteToggle; + dirFilterInput = elements.dirFilterInput; + dirDeepSearchBtn = elements.dirDeepSearchBtn; + dirBreadcrumbs = elements.dirBreadcrumbs; + dirFavorites = elements.dirFavorites; + dirFavoritesList = elements.dirFavoritesList; + dirRecents = elements.dirRecents; + dirRecentsList = elements.dirRecentsList; + dirSearchResults = elements.dirSearchResults; + dirSearchResultsList = elements.dirSearchResultsList; + dirList = elements.dirList; + dirStatus = elements.dirStatus; + dirNewBtn = elements.dirNewBtn; + dirSelectBtn = elements.dirSelectBtn; + convCwdInput = elements.convCwdInput; + + currentDirs = []; + filteredDirs = []; + activeDirIndex = -1; + clearDeepSearchState(); } // --- Event listener setup for directory browser elements --- export function setupDirectoryBrowserEventListeners() { + if (!browseBtn || !dirBrowser) return; + browseBtn.addEventListener('click', () => { const isHidden = dirBrowser.classList.contains('hidden'); if (isHidden) { - dirBrowser.classList.remove('hidden'); - browseTo(convCwdInput.value.trim() || ''); + void openDirectoryBrowser(); } else { dirBrowser.classList.add('hidden'); } }); - dirUpBtn.addEventListener('click', () => { - const currentBrowsePath = state.getCurrentBrowsePath(); - if (currentBrowsePath && currentBrowsePath !== '/') { - const parent = currentBrowsePath.replace(/\/[^/]+$/, '') || '/'; - browseTo(parent); - } + dirUpBtn?.addEventListener('click', () => { + void browseTo(getParentPath(state.getCurrentBrowsePath())); }); - dirSelectBtn.addEventListener('click', () => { - convCwdInput.value = state.getCurrentBrowsePath(); - dirBrowser.classList.add('hidden'); + dirFavoriteToggle?.addEventListener('click', () => { + toggleCurrentFavorite(); }); - dirNewBtn.addEventListener('click', async () => { - const name = await showDialog({ title: 'New folder', input: true, placeholder: 'Folder name', confirmLabel: 'Create' }); - if (!name || !name.trim()) return; - const newPath = state.getCurrentBrowsePath() + '/' + name.trim(); - const res = await apiFetch('/api/mkdir', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: newPath }), + dirSelectBtn?.addEventListener('click', () => { + confirmSelection(); + }); + + dirNewBtn?.addEventListener('click', async () => { + const name = await showDialog({ + title: 'New folder', + input: true, + placeholder: 'Folder name', + confirmLabel: 'Create' }); - if (!res) return; - const data = await res.json(); - if (data.ok) { - browseTo(newPath); + if (!name || !name.trim()) return; + const newPath = joinPath(state.getCurrentBrowsePath(), name.trim()); + let response; + try { + response = await fetch('/api/mkdir', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: newPath }), + }); + } catch (err) { + setStatusMessage(err.message || 'Failed to create folder', { error: true }); + return; + } + + const data = await response.json().catch(() => ({})); + if (response.ok && data.ok) { + await browseTo(newPath); + addRecentPath(newPath); + renderRecents(); } else { - showDialog({ title: 'Error', message: data.error || 'Failed to create folder' }); + setStatusMessage(data.error || `Failed to create folder (${response.status})`, { error: true }); + } + }); + + dirFilterInput?.addEventListener('input', () => { + renderLocalDirectoryList(); + const query = dirFilterInput.value.trim(); + if (query.length >= DEEP_SEARCH_MIN_QUERY_LENGTH && filteredDirs.length === 0) { + queueDeepSearch(false); + } else { + clearDeepSearchState(); + const count = filteredDirs.length; + setStatusMessage(count ? `${count} folder${count === 1 ? '' : 's'}` : 'No folders in this location'); + } + }); + + dirDeepSearchBtn?.addEventListener('click', () => { + queueDeepSearch(true); + }); + + dirBreadcrumbs?.addEventListener('click', (event) => { + const targetPath = renderSectionPathFromEvent(event.target, '.dir-breadcrumb-btn'); + if (!targetPath) return; + void browseTo(targetPath); + }); + + attachSectionPathHandler(dirFavoritesList, '.dir-chip'); + attachSectionPathHandler(dirRecentsList, '.dir-chip'); + attachSectionPathHandler(dirSearchResultsList, '.dir-item'); + + dirList?.addEventListener('click', (event) => { + const item = event.target.closest('.dir-item'); + if (!item) return; + const itemPath = item.dataset.path; + if (!itemPath) return; + void browseTo(itemPath); + }); + + dirList?.addEventListener('mousemove', (event) => { + const item = event.target.closest('.dir-item'); + if (!item) return; + const index = Number.parseInt(item.id.replace('dir-item-', ''), 10); + if (Number.isFinite(index) && index !== activeDirIndex) { + activeDirIndex = index; + updateActiveDirItem(); + } + }); + + dirBrowser.addEventListener('keydown', handleDirectoryBrowserKeyboard); + + convCwdInput?.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' || event.metaKey || event.ctrlKey) return; + event.preventDefault(); + if (dirBrowser.classList.contains('hidden')) { + dirBrowser.classList.remove('hidden'); + } + void browseToInputPath(); + }); + + convCwdInput?.addEventListener('blur', () => { + if (!convCwdInput.value.trim()) { + setCwdInputValidity(true); + return; } + void browseToInputPath(); }); } diff --git a/test/files-routes.test.js b/test/files-routes.test.js index 2230f9d..ccbbdd2 100644 --- a/test/files-routes.test.js +++ b/test/files-routes.test.js @@ -227,6 +227,68 @@ describe('file routes', () => { assert.equal(typeof response.body.error, 'string'); }); + it('validates browse search parameters', async () => { + const missingBase = await requestJson(baseUrl, 'GET', '/api/browse/search?q=data'); + assert.equal(missingBase.status, 400); + assert.equal(missingBase.body.error, 'base required'); + + const missingQuery = await requestJson(baseUrl, 'GET', `/api/browse/search?base=${encodeURIComponent(tmpRoot)}`); + assert.equal(missingQuery.status, 400); + assert.equal(missingQuery.body.error, 'q required'); + }); + + it('returns recursive directory search matches', async () => { + await fs.mkdir(path.join(tmpRoot, 'datasets', 'raw-data'), { recursive: true }); + await fs.mkdir(path.join(tmpRoot, 'datasets', 'processed-data'), { recursive: true }); + await fs.mkdir(path.join(tmpRoot, 'reports'), { recursive: true }); + + const response = await requestJson( + baseUrl, + 'GET', + `/api/browse/search?base=${encodeURIComponent(tmpRoot)}&q=${encodeURIComponent('data')}&depth=4&limit=10` + ); + assert.equal(response.status, 200); + assert.equal(response.body.base, tmpRoot); + assert.equal(Array.isArray(response.body.results), true); + assert.equal(response.body.results.length >= 2, true); + + const relPaths = response.body.results.map((item) => item.relPath); + assert.equal(relPaths.includes('datasets/raw-data'), true); + assert.equal(relPaths.includes('datasets/processed-data'), true); + }); + + it('skips hidden and heavy directories in browse search', async () => { + await fs.mkdir(path.join(tmpRoot, '.git', 'data-hidden'), { recursive: true }); + await fs.mkdir(path.join(tmpRoot, 'node_modules', 'data-packages'), { recursive: true }); + await fs.mkdir(path.join(tmpRoot, 'safe-data'), { recursive: true }); + + const response = await requestJson( + baseUrl, + 'GET', + `/api/browse/search?base=${encodeURIComponent(tmpRoot)}&q=${encodeURIComponent('data')}&depth=4&limit=20` + ); + assert.equal(response.status, 200); + const relPaths = response.body.results.map((item) => item.relPath); + assert.equal(relPaths.includes('safe-data'), true); + assert.equal(relPaths.some((relPath) => relPath.includes('.git')), false); + assert.equal(relPaths.some((relPath) => relPath.includes('node_modules')), false); + }); + + it('enforces search result limits and marks truncation', async () => { + for (let i = 0; i < 8; i++) { + await fs.mkdir(path.join(tmpRoot, `data-${i}`), { recursive: true }); + } + + const response = await requestJson( + baseUrl, + 'GET', + `/api/browse/search?base=${encodeURIComponent(tmpRoot)}&q=${encodeURIComponent('data')}&depth=2&limit=3` + ); + assert.equal(response.status, 200); + assert.equal(response.body.results.length <= 3, true); + assert.equal(response.body.truncated, true); + }); + it('creates a directory through mkdir endpoint', async () => { const target = path.join(tmpRoot, 'nested', 'folder'); const response = await requestJson(baseUrl, 'POST', '/api/mkdir', { path: target }); From 27a5fac8825bff2193e31a1a113d72f29c7e65d1 Mon Sep 17 00:00:00 2001 From: "Daniel J. Lewis" Date: Sat, 28 Feb 2026 14:18:52 -0500 Subject: [PATCH 2/2] clean up redundancy --- public/js/ui/directory-browser.js | 36 ++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/public/js/ui/directory-browser.js b/public/js/ui/directory-browser.js index 9acd08f..20d3687 100644 --- a/public/js/ui/directory-browser.js +++ b/public/js/ui/directory-browser.js @@ -187,19 +187,29 @@ function buildBreadcrumbSegments(pathValue) { return [{ label: normalized, path: normalized }]; } +function formatPathSummary(pathValue) { + const normalized = normalizePathValue(pathValue); + if (!normalized || normalized === '/') return '/'; + + const parts = normalized.split('/').filter(Boolean); + if (parts.length === 0) return '/'; + return parts[parts.length - 1]; +} + function renderBreadcrumbs() { if (!dirBreadcrumbs) return; const currentPath = state.getCurrentBrowsePath() || convCwdInput?.value || ''; const segments = buildBreadcrumbSegments(currentPath); + const hasRootSegment = segments.length > 0 && segments[0].label === '/'; dirBreadcrumbs.innerHTML = segments.map((segment, index) => { - const separator = index < segments.length - 1 - ? '/' - : ''; + const separator = hasRootSegment + ? (index > 1 ? '/' : '') + : (index > 0 ? '/' : ''); return ( - `' - + separator ); }).join(''); } @@ -244,7 +254,7 @@ function renderRecents() { renderPathChipSection(dirRecents, dirRecentsList, recents, 'dir-recent-chip'); } -function updateActiveDirItem() { +function updateActiveDirItem({ ensureVisible = false } = {}) { if (!dirList) return; const items = dirList.querySelectorAll('.dir-item'); if (!items.length) { @@ -261,6 +271,9 @@ function updateActiveDirItem() { item.setAttribute('aria-selected', isActive ? 'true' : 'false'); if (isActive) { dirList.setAttribute('aria-activedescendant', item.id); + if (ensureVisible && typeof item.scrollIntoView === 'function') { + item.scrollIntoView({ block: 'nearest' }); + } } }); } @@ -346,7 +359,10 @@ async function applyBrowseData(payload, { preserveFilter = false } = {}) { currentDirs = Array.isArray(payload.dirs) ? [...payload.dirs] : []; currentDirs.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); - if (dirCurrentPath) dirCurrentPath.textContent = payload.path; + if (dirCurrentPath) { + dirCurrentPath.textContent = formatPathSummary(payload.path); + dirCurrentPath.title = payload.path; + } if (convCwdInput) convCwdInput.value = payload.path; setCwdInputValidity(true); if (!preserveFilter && dirFilterInput) dirFilterInput.value = ''; @@ -481,20 +497,20 @@ function queueDeepSearch(force = false) { }, DEEP_SEARCH_DEBOUNCE_MS); } -function setActiveIndex(nextIndex) { +function setActiveIndex(nextIndex, { ensureVisible = false } = {}) { if (filteredDirs.length === 0) { activeDirIndex = -1; updateActiveDirItem(); return; } activeDirIndex = Math.max(0, Math.min(filteredDirs.length - 1, nextIndex)); - updateActiveDirItem(); + updateActiveDirItem({ ensureVisible }); } function moveActiveIndex(delta) { if (filteredDirs.length === 0) return; const start = activeDirIndex < 0 ? 0 : activeDirIndex; - setActiveIndex(start + delta); + setActiveIndex(start + delta, { ensureVisible: true }); } async function openActiveDirectory() {